Skip to content

Latest commit

 

History

History
251 lines (227 loc) · 17.8 KB

File metadata and controls

251 lines (227 loc) · 17.8 KB

CLAUDE.md

Guidance for Claude Code working in this repository.

What it is

Java 21 client for the TypeSafe API (https://api.typesafe.ai). Send a state (any JSON value) plus a set of Noul/Choice/Score questions, get back typed answers.

Module structure

core      — TypeSafeClient (interface; the only implementation, DefaultTypeSafeClient, is
            public and owns Builder — TypeSafeClient.builder(...) is a one-line delegate to
            DefaultTypeSafeClient.builder(...), so call sites don't change. A consumer can
            implement TypeSafeClient itself to decorate one, e.g. with caching).
            DefaultTypeSafeClient does one request, one response, no retries by default.
            Retry/backoff is the opt-in RetryingTypeSafeClient decorator (classifies by
            TypeSafeException, not raw responses); DeadlineTypeSafeClient caps a call's total
            time. Both are added via Builder.decorateWith(RetryingTypeSafeClient::decorate) etc. (each
            one goes outside the previous; build(Function) is outermost and keeps its type).
            Every decorator's static decorate(...) takes the delegate first, like
            MappingTypeSafeClient.decorate. build() throws if RetryingTypeSafeClient is added
            more than once. TokenCounter (LongAdder totals of EvaluateResponse#usage()) is an
            object the caller keeps, handing out its decorator via tokens::decorate, so the
            totals stay readable once the decorator is inside a stack.
            RetryingTypeSafeClient stops retrying once its returned future is done. ApiKey,
            TypeSafeException, and the wire DTOs (Answer, Question, Content,
            EvaluateRequest/EvaluateResponse, Usage, RequestId, Model, ModelDetails), all in
            io.github.dfa1.typesafe.core; plus the JsonCodec (io.github.dfa1.typesafe.json) +
            HttpTransport (io.github.dfa1.typesafe.transport) SPIs. Zero dependency on any
            JSON or HTTP library — DefaultTypeSafeClient talks to HttpTransport/JsonCodec,
            never to a concrete library directly, so the DTOs + JsonCodec alone are reusable
            (e.g. by a Kafka producer/consumer) without pulling in TypeSafeClient's HTTP
            concerns.
client-jdk — HttpTransport backed by java.net.http (artifact
            typesafe-java-client-jdk, class JdkHttpTransport, package
            io.github.dfa1.typesafe.jdk). Depends only on core. Discovered via
            ServiceLoader at Builder.build() time (or an explicit
            Builder.httpTransport(...) override).
client-okhttp — HttpTransport backed by OkHttp (artifact typesafe-java-client-okhttp, class
            OkHttpTransport, package io.github.dfa1.typesafe.okhttp). Depends only on core
            (plus OkHttp). An alternative to client-jdk for environments java.net.http doesn't
            cover, e.g. Android. Depends on `com.squareup.okhttp3:okhttp-jvm`, not `okhttp` —
            OkHttp 5.x publishes as Kotlin Multiplatform, and the bare `okhttp` coordinate
            resolves under plain Maven (no Gradle Module Metadata variant awareness) to an
            empty common-metadata jar with no classes; `okhttp-jvm` is the real JVM artifact.
            OkHttp lowercases response header names internally, unlike JdkHttpTransport (which
            preserves wire casing) — harmless since HttpTransportResponse#header(String) is a
            case-insensitive lookup, but `OkHttpTransportTest` asserts through `header(...)`
            rather than the raw `headers()` map for exactly this reason.
jackson2  — JsonCodec backed by Jackson 2.x. Depends only on core. Owns the `type`
            discriminator for Answer/Question via private Jackson mixins (addMixIn); Content
            (no discriminator — string/object/array on the wire; backs both
            EvaluateRequest.state and Question.instructions) via a custom serializer,
            registered through META-INF/services.
jackson3  — same, backed by Jackson 3.x (tools.jackson.databind).
testkit   — two TypeSafeClient test doubles, in io.github.dfa1.typesafe.testkit, depending
            only on core. RecordingTypeSafeClient implements TypeSafeClient directly, at the
            EvaluateRequest/EvaluateResponse level: `enqueueEvaluate`/`enqueueModels` queue a
            response (FIFO, no request matcher — a test already controls call order itself) to
            whichever evaluate()/evaluateAsync()/listModels() call comes next;
            `evaluateRequests()` records every evaluate()/evaluateAsync() call, in order. A
            call with nothing left queued throws (or, for evaluateAsync, fails its future with)
            an AssertionError. FailingTypeSafeClient decorates any TypeSafeClient (e.g. a
            RecordingTypeSafeClient, composing recording with periodic failure): every
            `failEvery`-th call (evaluate/evaluateAsync/listModels share one counter) throws a
            supplied exception instead of reaching the delegate.
mapping   — MappingTypeSafeClient (io.github.dfa1.typesafe.mapping), a TypeSafeClient decorator
            adding evaluateTyped(Content, Class<T>)/evaluateTypedAsync(...) for a caller-defined
            record T whose components carry @Noul/@Choice/@Score (each mirroring the matching
            Question factory's shape: @Noul/@Score take a double component, @Choice a String
            one; @Choice's options are a nested @Option[] since an annotation can't hold a Map).
            Reflects over T's record components (RecordComponent, not a codegen'd mapper) to
            build the EvaluateRequest's questions, keyed by component name, then constructs a
            new T from EvaluateResponse#answers() via T's canonical constructor — so a caller
            gets a typed record back instead of Map<String, Answer> and a manual
            (Answer.Noul)-style cast. Depends only on core in production; its own tests depend
            on testkit's RecordingTypeSafeClient (test scope only), the same test-double a
            consumer of this module would reach for.
local     — one public TypeSafeClient per model (io.github.dfa1.typesafe.local: LocalLayaTypeSafeClient,
            LocalQwenTypeSafeClient, LocalClefTypeSafeClient, each with a static load(Path)) over a package-private
            LocalTypeSafeClient base, evaluating in-process on ONNX
            Runtime, from a model directory the caller fills with `hf download` (docs/how-to.md): Laya =
            onnx-community's export — fp32 default, fp16 same answers/half size/slow on CPU — and Qwen q4;
            ~/.cache/typesafe-local by convention. Onnx.model(dir) finds the one .onnx file, so HF file
            names stay as-is. local/scripts is Python for test fixtures (laya/export_onnx.py: PyTorch fixture;
            tokenizer/reference.py: HF tokenizer ids; clef/reference.py: Clef sequences) plus
            clef/quantize_q4.py, the one script users run (Clef in 4 bits); the CI summary is
            .github/scripts/local_summary.py. Caller text goes
            through BpeTokenizer.encodeText (special tokens stay text); only templates use encode. No int8 Laya: dynamic int8 gave CPU-dependent answers,
            silently flat distributions on some x86 CPUs. Three engines behind
            a package-private Engine: LayaEngine (ModernBERT + decision head scoring [MASK] markers, a
            request's questions in one batch; a port of Laya's Python sequence builder), QwenEngine
            (one prefill per question, softmax over Yes/No/letter/digit logits) and ClefEngine (Qwen3.5-9B +
            joint schema head, a request in one forward pass). Own pure-Java BpeTokenizer
            (tokenizer.json); JSON through typesafe-java's JsonCodec (ServiceLoader, like the API client),
            so a jackson2/jackson3 module is needed at run time; BpeTokenizerTest checks ids
            against HF tokenizers, LayaEngineTest checks logits against PyTorch (fixtures under
            src/test/resources). Tests needing model files are @Tag("model"), excluded by the module's
            own excludedGroups (acceptance,model); opt in with -DexcludedGroups=acceptance -Dengine=laya.
            JevComparison (test scope, main) replays 104 requests against cached real-Jev answers
            (src/test/resources/jev); LocalTypeSafeClientBenchmark is JMH. WebGPU (Metal, macOS-only
            native lib): public only as LocalClefTypeSafeClient.loadOnGpu (stable, same answers as CPU); Laya/Qwen keep
            the package-private load(dir, gpu) for the harness — Laya's WebGPU answers drift between launches. LayaEngine reads onnx-community's layout
            (onnx/model.onnx, config.json "laya", bool marker_mask).
            The `Local engines` workflow runs tests, comparison and JMH on Linux/macOS and writes tables to
            the job summary.
bom       — dependency-management POM listing core/client-jdk/client-okhttp/jackson2/jackson3/testkit/mapping/local.
acceptance — live-API tests only; not published. `AbstractTypeSafeClientAcceptanceTest`
            holds every test method; one concrete subclass per HttpTransport/JsonCodec
            combination (`JdkHttpClientWithJackson2AcceptanceTest`,
            `JdkHttpClientWithJackson3AcceptanceTest`) supplies the pair via two abstract
            hooks, explicitly constructing the codec/transport (`new Jackson2Codec()`, ...)
            rather than relying on ServiceLoader, since this module deliberately has more
            than one of each on its test classpath at once. jackson2 and jackson3 both pull
            in `com.fasterxml.jackson.core:jackson-annotations` transitively, each at its own
            version; acceptance/pom.xml pins the newest one explicitly (bump it with either
            codec), or Maven's mediation can pick an older one and the other codec fails at
            runtime (`NoSuchFieldError`/`NoClassDefFoundError`). CI doesn't run acceptance, so a
            Jackson bump needs a local acceptance run. Also depends on
            `mapping` (test scope) — one test wraps `sut` in a `MappingTypeSafeClient` to
            exercise a `@Noul`/`@Choice`/`@Score`-annotated record against the live API.
cli       — command-line entry point (`Main`), over client-jdk + jackson3. Its main artifact
            is a plain (non-executable) jar of just this module's own classes; the runnable
            uber-jar (maven-shade-plugin) is published separately under the `all` classifier
            (`typesafe-java-cli-VERSION-all.jar` — `java -jar` this one), so a normal
            dependency on `typesafe-java-cli` never pulls in unrelocated, bundled copies of
            its dependencies. Flat flags: `--state <text>` (the only `Content` shape it
            supports — plain text), repeatable
            `--noul`/`--choice`/`--score <name>=<instructions>[|opt1,opt2,...]`,
            optional `--model <id>`, `--verbose`/`--timing` (request id / response time to
            stderr), `--version` (prints the jar's `Implementation-Version` manifest entry,
            set by the shade plugin, and exits without calling the API), `--help`/`-h` (prints
            usage and exits without calling the API). Stdout is silent
            unless `--print <name>` (that answer's value) or `--verbose` (the full
            `EvaluateResponse` as pretty-printed JSON, via `JsonCodec.writeValueAsPrettyString`)
            is given.

Dependency rule: client-jdk → core, client-okhttp → core, jackson2 → core, jackson3 → core, testkit → core, mapping → core (mapping's own tests additionally depend on testkit, test scope only), acceptance → core, client-jdk, client-okhttp, jackson2, jackson3, mapping (test scope only), cli → core, client-jdk, jackson3, local → core (plus ONNX Runtime; its own tests additionally depend on client-jdk and jackson2, test scope only) — nothing production depends on acceptance, cli, or testkit. See ADR 0001 for why the SPIs exist at all.

Commands

./mvnw clean verify                                                              # build + unit tests, all modules
./mvnw test -pl jackson2 -am                                                     # one module (+ its dependencies)
./mvnw test -pl jackson2 -am -Dtest=Jackson2CodecTest -Dsurefire.failIfNoSpecifiedTests=false

No step here uses install — a routine build has no reason to write into ~/.m2/repository. -am ("also make") rebuilds a module's dependencies within the same reactor run instead of resolving them from the local repo, so a single-module command works right after a fresh clone. -Dsurefire.failIfNoSpecifiedTests=false is only needed alongside a -Dtest= filter + -am: without it, surefire errors on the upstream modules -am rebuilds that don't contain the named test class.

Acceptance tests (in acceptance, one concrete class per HttpTransport/JsonCodec combination) are @Tag("acceptance"), hit the real TypeSafe API, and need a token at ~/.typesafe.apikey. Excluded from a routine ./mvnw test via the excludedGroups=acceptance property (surefire). Opt in with:

./mvnw test -pl acceptance -am -DexcludedGroups=

Design decisions

  • core has zero Jackson dependency and the DTOs carry zero Jackson annotations. Polymorphism (Answer/Question's type discriminator) is wired up entirely inside each codec module via mixins, not on the DTOs. Adding a third JSON library means adding one more codec module; core/client-jdk don't change.
  • JsonCodec is discovered via ServiceLoader, not a hard compile dependency. A consumer that depends on client-jdk but forgets a codec module gets a clear IllegalStateException from Builder.build(), not a NoClassDefFoundError.
  • Small public API. Don't expose internals — when in doubt, leave it out or make it package-private.

Testing

JUnit 6 + AssertJ (assertThat(...), not JUnit's Assertions.assertEquals/assertTrue) + Mockito (BDDMockito: static-import only given/then, e.g. given(mock.m()).willReturn(v) / then(mock).should().m() — never willReturn/willThrow/verify unqualified). JUnit Pioneer's @SetEnvironmentVariable (core only, for ApiKeyTest) sets an env var for one test method; needs the --add-opens java.base/java.util/java.lang=ALL-UNNAMED flags on surefire's argLine in the root pom (Java 17+ blocks the reflection it uses otherwise). Prefer testing behavior through the real classes involved (e.g. Jackson2CodecTest/Jackson3CodecTest exercise the codec, not a bare ObjectMapper) — this is what caught that Jackson 3's builder API differs from Jackson 2's mutable ObjectMapper during the initial split. TypeSafeClientTest mocks HttpTransport/JsonCodec to verify TypeSafeClient calls the SPIs correctly, without a real HTTP round trip. Every test has // Given / // When / // Then comments marking its three phases (omit // Given when there's nothing to arrange). The pre-built instance a test invokes behavior on is named sut (e.g. a Jackson2Codec field, or an object constructed in // Given that // When calls a method on); the value produced by the operation under test in // When is named result. A static factory call with nothing further invoked on it just produces result — there's no separate sut.

Documentation is part of every change

Docs live under docs/, structured by Diataxis: tutorial.md (learning-oriented walkthrough), how-to.md (task-oriented recipes), reference.md (API surface), explanation.md (design rationale). A change to the public API, module structure, or a documented behavior updates whichever of these apply, in the same commit — plus CHANGELOG.md under [Unreleased]. adr/ and released CHANGELOG.md sections are exempt — they describe the past.

Each [Unreleased] entry is one line: a bold title and a short one-sentence description — not a multi-paragraph write-up. The publish workflow copies the released section verbatim into the GitHub release notes, so a long entry is a long release note.

- **Title** — one-sentence description of what changed.

Code, docs and the CHANGELOG entry go in the same commit — no follow-up "CHANGELOG: …" commits. A commit can't link to its own hash, so a direct commit's entry has no link; append (#<PR-number>) when the change lands as a PR.

Releasing

Prerequisites (one-time): namespace io.github.dfa1 registered at central.sonatype.com (GitHub auto-validates), a user token from there added to ~/.m2/settings.xml as server id central, and a published GPG key. In CI, the publish workflow (triggered by pushing a v* tag) needs GPG_PRIVATE_KEY/GPG_PASSPHRASE/CENTRAL_USERNAME/CENTRAL_PASSWORD secrets in the maven environment.

./mvnw --batch-mode release:clean release:prepare \
    -DreleaseVersion=<version> -DdevelopmentVersion=<next>-SNAPSHOT
git push && git push --tags          # GitHub Actions deploys the tag to Maven Central

release:prepare bumps every module to <version> (autoVersionSubmodules=true), tags it v<version>, then bumps to <next>-SNAPSHOT — two local commits, nothing pushed (pushChanges=false) until the explicit git push above. acceptance opts out of publishing (maven.deploy.skip/skipPublishing in its pom) — it only exists to run its own tests. cli is published: a plain main jar plus the runnable uber-jar under the all classifier (see Module structure above), GPG-signed like every other artifact. CHANGELOG.md needs a ## [<version>] - <date> section before tagging: the publish workflow extracts that section verbatim as the GitHub release notes, then appends a direct link to the all jar (and its .asc) on Maven Central — waitUntil=published on the deploy step means it's already live by the time the release is created, so publish.yml links to it instead of re-uploading the same bytes as a release asset.