Skip to content

@memhtml/eval

@memhtml/eval: the fixture corpus generator, the refusable retrieval discrimination gate, and the write-path discrimination arm. The retrieval corpus is never committed; each run regenerates it from a seed. The write-path corpus is committed, because its labels are the oracle.

The gate asks one question: can the retrieval stack rank a memory above its own high-similarity WRONG twin? controls turns a true claim into a plausible impostor along three divergence families (negation, numeric, variant), validates each control against its own family’s predicate from @memhtml/domain, and discards a control that failed to diverge. corpus is a pure function of (seed, now): it yields the same memory specs and probes on any machine, with every timestamp anchored as an offset behind the run so that salience decay cannot move the ranks from one calendar day to the next. fixture is the one module that writes, rendering a spec into a real git repository under a temp directory. harness builds the stack the gate measures (that fixture, a real database with the shipped migrations, the real indexer and the real four-arm retrieval) and substitutes only the embedder: a deterministic one whose cosine relations are a pure function of the text, so the numbers reproduce without credentials. discriminate runs the probes and reports two numbers, of which the strict one is the gate: mrr against a floor of 0.85, and a per-probe inversion count where one inversion fails the run regardless of MRR. run picks the mode and shapes the outcome so that a skipped gate never looks like a passing one: fake mode runs everywhere, and live mode without credentials reports skipped: true and passed: false.

The write-path arm asks the other question the 2026-08-27 eval plan posed: does the WRITE path tell a good candidate memory from a bad one? write-path-corpus is a labeled corpus of 62 candidate memories (31 the gate should write, 31 it should refuse), each with the provenance of its label (the contract clause or design rule that decides it) and two fixture transcripts every good quote is a verbatim span of. write-path-gate composes the production gate from its own functions in the order the fleet runs them: the consolidator door’s schema decode, readable-session check and verbatim-quote check, then the sleep phase’s per-candidate refusal (candidateRefusalFor). Nothing is re-implemented. write-path-metrics scores the decisions with refusal as the positive class (precision, recall, F1, the four-cell confusion matrix, and a per-class breakdown naming the items that disagreed with their label). write-path-run runs the arm, derives the floor (the plan named none, so it is the measured baseline F1 0.9333 minus five points, 0.88, stated as such), and freezes every decision into fixtures/write-path-discrimination.manifest.json, which the test:eval tier replays decision for decision. pnpm freeze:write-path is the one way that manifest changes. Three failure classes are labeled bad and accepted by the gate today (a duplicated evidence quote, twice; a claim that is a verbatim transcript span), and one good item is refused for a whitespace-padded session id; KNOWN_GAP_CLASSES states exactly that set and the tier fails if the set moves in either direction.

The write-path ACCEPTANCE arm asks the question the discrimination arm cannot: does dedup-merge FOLD the pairs it should? A phase that refuses everything scores perfectly on refusal, which is what production did on 2026-09-10 and 2026-09-11 (vetoed: 171/171, vetoed: 178/178, merged: 0). write-path-acceptance-corpus is 19 labeled groups (12 merge, 7 keep), each the answer a model would have given, with the provenance of its label. write-path-acceptance runs them through the phase’s own fold path — dedupMergeTextFor, groupPairsFor, and mergeOutcomes under DEDUP_ADMIT_FLOOR, all imported — with a fold as the positive class, reports acceptance (folds over merge pairs), precision, and where every refused pair stopped (read off the filter’s own MergeStage, a veto refined to its predicate by mergeVetoReasons), and gates on an acceptance floor (measured baseline 1.0 minus five points, 0.95) plus zero folds of a keep pair. ACCEPTANCE_KNOWN_GAP_CLASSES is empty: the two mechanisms behind the all-veto runs — numericTokenDivergent over the whole article vetoing the same claim for an incidental number in the body (incidental-number), and a role guard that claimed both roles folding at most one member of a model group (group-fanout) — measured 8 of 12 under the old rules and 14 of 14 under the current ones, which read numbers off the gist and admit a repeated keeper. The keep side holds the counterexamples the new rules must still refuse (a numeric conflict in the claim, a negation or variant qualifier only in the body, a templated series whose claims quote different ids), and BODY_NUMBER_ONLY_GROUP states the shape the gist rule no longer refuses, measured outside the gated corpus.

This package sits above @memhtml/contracts, @memhtml/domain, @memhtml/html, @memhtml/index, @memhtml/llm, @memhtml/store, @memhtml/sleep and @memhtml/consolidator (the last two only for the write-path arm, which imports the gate it measures), and apps/cli depends on it: memhtml eval discriminate is runDiscrimination, and @memhtml/sleep’s merge takes discriminationGate as its pre-merge gate from the CLI’s wiring, because the sleep package supplies no default.

The package publishes one import path, @memhtml/eval, which re-exports the twelve modules below, so the reference on this page is the whole exported surface. gen-fixture.ts is the body of pnpm gen:fixture and is not exported; scripts/freeze-write-path.mjs is the body of pnpm freeze:write-path and lives outside src/ because it resolves a repo fixture the published package does not ship.

Module What it holds
controls.ts DIVERGENCE_FAMILIES, the three flips (negationFlip, numericFlip, variantFlip), familyPredicate and deriveControl.
corpus.ts MemorySpec, Probe, CorpusSpec, the defaults (DEFAULT_SEED, DEFAULT_CORPUS_SIZE, DEFAULT_PROBE_COUNT), quantizeNow, articleFor, queryFor and buildCorpus.
discriminate.ts MRR_FLOOR, PROBE_LIMIT, runProbes, summarize, discriminate, runFloor, describeFailure and the report shapes.
fixture.ts FixtureCorpus, FixtureOptions, memoryFileFor, writeCorpus and makeFixtureCorpus.
harness.ts EvalStack, StackOptions, the embedders (fakeEmbedder, failingEmbedder, liveEmbedder, fakeVector, FAKE_DIM), buildStack and withStack.
run.ts EvalOptions, EvalOutcome, BEDROCK_TOKEN_VAR, hasBedrockCredentials, runDiscrimination, DiscriminationFailed and discriminationGate.
write-path-corpus.ts WRITE_PATH_CORPUS (62 labeled candidates), TRANSCRIPTS, KNOWN_GAP_CLASSES, CEILINGS, MANY_SPANS, padTo and the fixture session ids.
write-path-gate.ts admitCandidate, GATE_STAGES, GateDecision, GateBatch: the production gate composed stage by stage.
write-path-metrics.ts summarizeWritePath, confusionOf, breakdownByClass, agrees, describeWritePath and the report shapes.
write-path-acceptance-corpus.ts ACCEPTANCE_CORPUS (16 labeled groups), ACCEPTANCE_KNOWN_GAP_CLASSES and the group shapes.
write-path-acceptance.ts runWritePathAcceptance, ACCEPTANCE_FLOOR, ACCEPTANCE_BASELINE, impliedPairs, decideAcceptance, summarizeAcceptance, acceptanceByClass, describeAcceptance and the report shapes.
write-path-run.ts runWritePathDiscrimination, WRITE_PATH_F1_FLOOR, WRITE_PATH_BASELINE_F1, GATE_SOURCES, MANIFEST_FILENAME, manifestFor, replayDrift, readManifest, writeManifest, withBatch, decideAll.

A failed gate, as a tagged error.

Tagged so it is a failure like every other in the system rather than a bare value. The CLI’s codeFor switches on _tag, and ERR_DISCRIMINATION_FAILED, the error code design §8 named for exactly this refusal, would otherwise have no producer at all and degrade to ERR_UNKNOWN.

The whole outcome rides along, because a refusal an operator cannot reproduce is a refusal they will override. seed regenerates the corpus that failed, and inversions says which probes.

new DiscriminationFailed(outcome): DiscriminationFailed;

EvalOutcome

DiscriminationFailed

readonly _tag: "DiscriminationFailed" = "DiscriminationFailed";
readonly outcome: EvalOutcome;
get reason(): string;

The one-line summary, used as the envelope’s human message.

string

One class’s agreement with its labels.

readonly agreed: number;

Decisions that matched the label: folded for merge, refused for keep.

readonly class: string;
readonly disagreed: readonly string[];
readonly label: AcceptanceLabel;
readonly pairs: number;

One member of a group, as the phase’s CorpusRow would carry it. Order in the array is corpus order.

readonly body: string;

The <article>’s text content, files.body_text.

readonly gist: string;

The <mark> claim, files.gist.

readonly path: string;

What runWritePathAcceptance takes.

readonly optional acceptanceFloor?: number;
readonly optional corpus?: readonly LabeledGroup[];

The report the gate reads.

readonly acceptance: number;

accepted / eligible, four decimals; 0 when nothing was eligible.

readonly acceptanceFloor: number;
readonly accepted: number;

Folds among the eligible.

readonly decisions: readonly PairDecision[];
readonly eligible: number;

Pairs from merge groups: the ones that should fold.

readonly f1: number;
readonly foldedKeep: number;

Folds among the guarded. Any value above zero fails the tier.

readonly groups: number;
readonly guarded: number;

Pairs from keep groups.

readonly pairs: number;
readonly passed: boolean;

True when both labels are present AND no keep pair folded AND acceptance >= acceptanceFloor. One label alone is a failure, not a vacuous pass, for the reason the discrimination arm gives.

readonly perClass: readonly AcceptanceClassBreakdown[];
readonly precision: number;

accepted / (accepted + foldedKeep), four decimals; 0 when nothing folded.

readonly recall: number;

Same as acceptance, stated under the name the discrimination arm uses.


The text of a memory as the flips see it, meaning the claim then each body paragraph.

readonly body: readonly string[];
readonly claim: string;

One class’s agreement with its labels.

readonly agreed: number;

Decisions that matched the label: rejected for bad, accepted for good.

readonly class: string;
readonly disagreed: readonly string[];

Ids of the items whose decision contradicted the label.

readonly items: number;
readonly label: WritePathLabel;

The four cells, named by what happened rather than by TP/FP letters a reader has to re-derive.

badRejected is the true positive, goodRejected the false positive, badAccepted the false negative, goodAccepted the true negative.

readonly badAccepted: number;
readonly badRejected: number;
readonly goodAccepted: number;
readonly goodRejected: number;

The whole fixture: the memories to write, their access history, and the probes.

readonly access: readonly AccessSpec[];

state.access rows the harness seeds before running the probes.

readonly memories: readonly MemorySpec[];
readonly now: number;

The quantized run instant every stamp is anchored behind, UTC millis. See quantizeNow.

readonly probes: readonly Probe[];
readonly seed: number;

A derived control, holding the flipped text plus the family that produced it.

readonly body: readonly string[];

ClaimText.body

readonly claim: string;

ClaimText.claim

readonly family: DivergenceFamily;
readonly note: string;

What changed, for the probe’s own record.


The whole run’s outcome. This is the eval.discrimination envelope payload.

readonly corpusMrr: number;

Mean reciprocal rank over the WHOLE hit list, unitless in [0, 1], four decimals.

Reported rather than gated, because corpus size dominates it more than ranking quality does. DEFAULT_ARM_LIMIT is 40, which is 13% of a 300-file fixture and under 1% of a real corpus, so the two query-blind arms cover a far larger share of a fixture than of production. The value is seed- and parameter-dependent — it moves with the corpus size, the probe count, and the seed — but it rises with corpus size on this generator while the inversion count stays flat, which is exactly why a gate on this number would be a gate on how big the fixture is.

readonly degradedProbes: number;

Probes ranked by a degraded (vector-arm-free) search.

readonly discriminated: number;

Probes whose target strictly outranked all of its controls.

readonly inversions: readonly ProbeResult[];

Probes with at least one inversion. The gate fails when this is non-zero.

readonly mode: EvalMode;
readonly mrr: number;

Mean reciprocal rank over {target} ∪ controls, unitless in [0, 1], four decimals.

This is the gated number, and its coordinate space is the discrimination set, not the corpus. Design §5 states the floor in the same clause as rank(target) < min(rank(control)), so the space the sentence is about is the target against its own impostors. 1.0 means every target beat every control outright. See corpusMrr for the other reading, which is reported and not gated.

readonly mrrFloor: number;

The floor mrr was measured against.

readonly passed: boolean;

True when there are zero inversions AND mrr >= mrrFloor.

readonly probes: number;
readonly results: readonly ProbeResult[];

Both embed ports plus a call counter.

  • EmbedPort.QueryEmbedPort
readonly calls: () => number;

number

readonly embed: (texts) => Effect<readonly Float32Array<ArrayBufferLike>[], ModelUnavailable>;

readonly string[]

Effect<readonly Float32Array<ArrayBufferLike>[], ModelUnavailable>

EmbedPort.embed
readonly embedQuery: (text) => Effect<Float32Array<ArrayBufferLike>, ModelUnavailable>;

string

Effect<Float32Array<ArrayBufferLike>, ModelUnavailable>

QueryEmbedPort.embedQuery

What runDiscrimination takes.

readonly optional env?: Readonly<Record<string, string | undefined>>;

Injected for tests, so the credential branch is drivable without touching the environment.

readonly optional mode?: EvalMode;

fake by default, the mode that works in CI and whose numbers are reproducible.

readonly optional mrrFloor?: number;
readonly optional now?: number;

The run instant to anchor the corpus’s stamps behind, UTC millis. The clock when absent.

readonly optional probes?: number;
readonly optional seed?: number;
readonly optional size?: number;

What memhtml eval discriminate emits, the report plus which mode was asked for.

readonly corpusMrr: number;

Mean reciprocal rank over the WHOLE hit list, unitless in [0, 1], four decimals.

Reported rather than gated, because corpus size dominates it more than ranking quality does. DEFAULT_ARM_LIMIT is 40, which is 13% of a 300-file fixture and under 1% of a real corpus, so the two query-blind arms cover a far larger share of a fixture than of production. The value is seed- and parameter-dependent — it moves with the corpus size, the probe count, and the seed — but it rises with corpus size on this generator while the inversion count stays flat, which is exactly why a gate on this number would be a gate on how big the fixture is.

DiscriminationReport.corpusMrr

readonly corpusSize: number;
readonly degradedProbes: number;

Probes ranked by a degraded (vector-arm-free) search.

DiscriminationReport.degradedProbes

readonly discriminated: number;

Probes whose target strictly outranked all of its controls.

DiscriminationReport.discriminated

readonly inversions: readonly ProbeResult[];

Probes with at least one inversion. The gate fails when this is non-zero.

DiscriminationReport.inversions

readonly mode: EvalMode;

DiscriminationReport.mode

readonly mrr: number;

Mean reciprocal rank over {target} ∪ controls, unitless in [0, 1], four decimals.

This is the gated number, and its coordinate space is the discrimination set, not the corpus. Design §5 states the floor in the same clause as rank(target) < min(rank(control)), so the space the sentence is about is the target against its own impostors. 1.0 means every target beat every control outright. See corpusMrr for the other reading, which is reported and not gated.

DiscriminationReport.mrr

readonly mrrFloor: number;

The floor mrr was measured against.

DiscriminationReport.mrrFloor

readonly now: number;

The quantized run instant the corpus’s stamps were anchored behind, UTC millis. The other half of reproducing a failing run, since the corpus is a function of (seed, now). 0 on a skipped run, where no corpus was generated.

readonly passed: boolean;

True when there are zero inversions AND mrr >= mrrFloor.

DiscriminationReport.passed

readonly probes: number;

DiscriminationReport.probes

readonly requested: EvalMode;

The mode the caller asked for, which differs from mode only on a refused live run.

readonly results: readonly ProbeResult[];

DiscriminationReport.results

readonly seed: number;

The seed the corpus was generated at, so a failing run is reproducible.

readonly skipped: boolean;

True when live mode was requested with no credentials present. A skipped run is passed: false and carries zero probes, so it is a refusal rather than a result.

readonly optional skipReason?: string;

Why it was skipped, absent otherwise.


A built stack, holding the fixture, the database, and retrieval over both.

readonly db: DatabaseShape;
readonly embedCalls: () => number;

number

readonly fixture: FixtureCorpus;
readonly indexed: number;

How many files the indexer projected, so a caller can assert the corpus really landed.

readonly retrieval: RetrievalShape;

A generated fixture repo, with the root, the git service over it, and the spec behind it.

readonly cleanup: () => Promise<void>;

Promise<void>

readonly git: GitShape;
readonly root: string;
readonly spec: CorpusSpec;
readonly written: number;

How many files were written, generated artifacts excluded.


What makeFixtureCorpus takes. Every field has a default, so a caller can pass nothing.

readonly optional now?: number;

The run instant the corpus’s stamps are anchored behind, UTC millis. Effect’s Clock when absent, which is the one place the fixture pipeline consults time at all — buildCorpus requires it, so a test pinning this value pins every stamp in the tree.

readonly optional probes?: number;
readonly optional root?: string;

Where to generate. A fresh temp directory when absent.

readonly optional seed?: number;
readonly optional size?: number;

One fixture transcript, as the batch would offer it.

readonly jsonl: string;

JSONL, one object per line, in the shape Claude Code writes under ~/.claude/projects.

readonly sessionId: string;

The lexical-floor scenario runs the same suite with no vector arm.

Design §5 requires two things of it: no error, and the lexical arm still beating the controls on the probes that carry lexical signal. Only the SECOND half is a subset claim, and it has to be. A numeric-family control differs from its target by one token, which the FTS arm cannot order, so demanding the full gate without vectors would be demanding the vector arm be unnecessary.

lexicallyDiscriminated is therefore the number reported and asserted, not passed.

readonly allDegraded: boolean;

True when every probe was ranked degraded, which asserts that the arm really is absent.

readonly lexicallyDiscriminated: number;

Probes the lexical floor still discriminated.

readonly probes: number;
readonly results: readonly ProbeResult[];

Every result, so a caller can see which families survive without vectors.


One frozen decision: enough to detect drift, nothing that needs a transcript to read.

readonly accepted: boolean;
readonly class: string;
readonly id: string;
readonly label: "good" | "bad";
readonly stage: GateStage;

What one batch made readable: the transcripts on disk, keyed the way the door keys them.

readonly reachable: readonly ReachableTranscript[];

One candidate’s outcome.

readonly accepted: boolean;
readonly reason: string | null;

The production function’s own reason text, null when accepted. Truncated to one line.

readonly stage: GateStage;

One labeled candidate.

candidate is unknown on purpose: a bad item may violate the type the door decodes (a bare string where an entity object belongs, a missing field), and a typed field would make those shapes unrepresentable. The gate’s first stage IS the decode, so the corpus has to be able to hand it something the type refuses.

readonly candidate: unknown;
readonly class: string;

For a bad item, its failure class (what is wrong with it). For a good item, its shape (what the gate has to get RIGHT to accept it). The per-class breakdown groups on this.

readonly id: string;

Stable id, g01…g31 for good, b01…b31 for bad. The manifest keys on it.

readonly label: WritePathLabel;
readonly provenance: string;

The rule that decides the label, with the file that holds it.


One candidate’s decision beside its label.

readonly accepted: boolean;
readonly class: string;
readonly id: string;
readonly label: WritePathLabel;
readonly reason: string | null;
readonly stage: GateStage;

One labeled group.

readonly class: string;

For a merge group, its shape (what the fold has to get RIGHT to accept it). For a keep group, its divergence (why the veto must refuse it). The per-class breakdown groups on this.

readonly id: string;

Stable id, m01… for merge, k01… for keep. Reports and gap sets key on it.

readonly label: AcceptanceLabel;
readonly members: readonly AcceptanceMember[];

Two or more members, oldest first.

readonly provenance: string;

The rule that decides the label, with the file that holds it.


One memory to write, before it becomes HTML.

readonly optional archivedAt?: string;

Set on the archived members, which carry memhtml-status: archived and a memhtml-archived stamp.

readonly body: readonly string[];
readonly claim: string;

The sentence the memory turns on. It becomes the <mark> span and therefore files.gist.

readonly confidence: number;
readonly createdAt: string;
readonly entities: readonly string[];
readonly extras: readonly string[];

Extra article markup after the claim paragraph, the element kit this memory exercises.

readonly importance: number;
readonly links: readonly object[];
readonly memoryType: string;
readonly path: string;
readonly optional sessionId?: string;
readonly tags: readonly string[];
readonly title: string;
readonly updatedAt: string;
readonly optional validUntil?: string;

One oriented pair’s decision beside its group’s label.

readonly class: string;
readonly dropPath: string;
readonly folded: boolean;
readonly group: string;
readonly id: string;

<group id>/<drop basename>, stable across runs.

readonly keepPath: string;
readonly label: AcceptanceLabel;
readonly stage: AcceptanceStage;

One discrimination probe: a query, the memory that answers it, and the near-twins that do not.

controlPaths are ordered by family so a failure names the axis. Each control is validated against its OWN family’s divergence predicate at generation time. See controls.ts.

readonly controlPaths: readonly string[];
readonly families: readonly DivergenceFamily[];
readonly query: string;
readonly targetPath: string;

One probe’s outcome.

readonly controlRanks: readonly object[];

Each control’s 1-based rank in the whole hit list, null when absent from the hits.

readonly corpusReciprocalRank: number;

1 / targetRank, or 0 when the target was absent. The term of corpusMrr.

readonly degraded: boolean;

True when the vector arm did not fire for this probe.

readonly discriminated: boolean;

True when the target strictly outranks EVERY control. A control the search never returned counts as outranked, since being absent is worse than being last.

readonly discriminationRank: number;

1-based rank of the target within {target} ∪ controls ALONE, the space the gate is stated in. 1 means the target beat every one of its own impostors. Never null, because the target is always a member of this set, so an absent target ranks last rather than nowhere.

readonly query: string;
readonly reciprocalRank: number;

1 / discriminationRank. The term of DiscriminationReport.mrr.

readonly targetPath: string;
readonly targetRank: number | null;

1-based rank of the target within the WHOLE returned hit list, or null when it was not returned. The scope is every active memory in the corpus. Compare against discriminationRank, which measures the same position in a different space.


One decision that replayed differently from the frozen one.

readonly fresh:
| {
accepted: boolean;
stage: GateStage;
}
| null;
readonly frozen:
| {
accepted: boolean;
stage: GateStage;
}
| null;
readonly id: string;

What buildStack takes beyond the fixture options.

readonly optional embedder?: EvalEmbedder;

The embedder to measure through. fakeEmbedder() when absent.

readonly optional now?: number;

The run instant the corpus’s stamps are anchored behind, UTC millis. Effect’s Clock when absent, which is the one place the fixture pipeline consults time at all — buildCorpus requires it, so a test pinning this value pins every stamp in the tree.

FixtureOptions.now

readonly optional probes?: number;

FixtureOptions.probes

readonly optional root?: string;

Where to generate. A fresh temp directory when absent.

FixtureOptions.root

readonly optional seed?: number;

FixtureOptions.seed

readonly optional size?: number;

FixtureOptions.size


What a variant-family derivation needs beyond the target’s own text.

readonly anchor: string;

The token the qualifier is inserted after, a product or host name in the claim.

readonly qualifier: string;

A VARIANT_QUALIFIERS member, such as pro, beta, or legacy.


The freeze/replay manifest.

readonly corpusSha256: string;

sha256 of the corpus as JSON, so a changed candidate is attributable.

readonly decisions: readonly FrozenDecision[];
readonly f1Floor: number;
readonly frozenAt: string;

When the freeze ran, ISO-8601. Informational; not compared on replay.

readonly gate: readonly object[];

The stages, in order, and the production functions each one calls.

readonly memhtmlVersion: string;

The memhtml workspace version the decisions were frozen at.

readonly metrics: object;
readonly accuracy: number;
readonly confusion: ConfusionMatrix;
readonly f1: number;
readonly items: number;
readonly precision: number;
readonly recall: number;
readonly schemaVersion: number;
readonly transcriptsSha256: string;

sha256 of the two transcripts’ JSONL, so a changed fixture line is attributable.


What runWritePathDiscrimination takes.

readonly optional corpus?: readonly LabeledCandidate[];
readonly optional f1Floor?: number;
readonly optional transcripts?: readonly FixtureTranscript[];

The report the gate reads. This is the eval.write-path payload shape.

readonly accuracy: number;

(badRejected + goodAccepted) / items, four decimals.

readonly bad: number;
readonly confusion: ConfusionMatrix;
readonly decisions: readonly LabeledDecision[];
readonly f1: number;

Harmonic mean of the two, four decimals; 0 when either is 0.

readonly f1Floor: number;
readonly good: number;
readonly items: number;
readonly passed: boolean;

True when the corpus is non-empty AND carries both labels AND f1 >= f1Floor.

An empty corpus, or one with a single label, is a FAILURE rather than a vacuous pass: a mean over nothing is not a measurement, and a gate scored only on good items (or only on bad) cannot have discriminated anything.

readonly perClass: readonly ClassBreakdown[];
readonly precision: number;

badRejected / (badRejected + goodRejected), four decimals; 0 when nothing was rejected.

readonly recall: number;

badRejected / (badRejected + badAccepted), four decimals; 0 when nothing was bad.

type AcceptanceLabel = "merge" | "keep";

Which way the label points. merge: every member folds into the oldest. keep: none does.


type AcceptanceStage =
| "threshold"
| "veto:negation"
| "veto:numeric"
| "veto:variant"
| "self"
| "role-guard"
| "cap"
| "folded";

Where a pair’s fate was decided. folded is the outcome the corpus’s merge label asks for.


type DivergenceFamily = "negation" | "numeric" | "variant";

Which divergence a control embodies. Named on the probe, so a failure says which axis broke.


type EvalMode = "live" | "fake";

Which embedder produced the numbers. Recorded on the report so a pass is never ambiguous.


type GateStage =
| "door:schema"
| "door:grounding"
| "door:quote"
| "phase:refusal"
| "accepted";

Where a candidate’s fate was decided. accepted is the fifth outcome: it cleared all four.


type WritePathLabel = "good" | "bad";

Which way the label points.

const ACCEPTANCE_BASELINE: 1 = 1;

The measured baseline this floor was derived from, so the derivation is checkable.

Measured 2026-09-11 at memhtml 0.14.0 on corpus v2 (12 merge groups implying 14 pairs, 7 keep groups implying 7 pairs): acceptance 1.0 (14 of 14), precision 1.0 (no keep pair folded). Corpus v1 under the rules before this one measured 0.6667 (8 of 12): three incidental-number pairs stopped at veto:numeric because the numeric predicate read the whole article, and one group-fanout pair stopped at role-guard because the guard claimed the keeper after the first fold. Both classes now fold, and the known-gap set is empty.


const ACCEPTANCE_CORPUS: ReadonlyArray<LabeledGroup>;

const ACCEPTANCE_FLOOR: number;

Acceptance floor: the baseline minus five points, floored to two decimals — the discrimination arm’s derivation, for the same reason. The floor is the alarm for a class-sized regression (a predicate that starts firing on a class it did not), and the known-gap set is the alarm for a one-pair one. A floor at the baseline would fire on every deliberate corpus edit.


const ACCEPTANCE_KNOWN_GAP_CLASSES: ReadonlyArray<string> = [];

The classes whose label the fold path is KNOWN to disagree with, stated so the tier fails if the set moves in either direction. Empty as of the rules measured 2026-09-11: every merge pair folds and every keep pair is refused.

Two classes used to be here. incidental-number (the same claim, one side carrying a date, a ticket id, or an exit code the other lacks) stopped at veto:numeric while numericTokenDivergent read the whole gist\nbody_text; it reads the gist now. group-fanout (a group of three implying two pairs with one keeper) stopped its second pair at role-guard while the guard claimed both roles of a committed pair; it admits a repeated keeper now.


const BATCH_SESSION_IDS: ReadonlyArray<string>;

The batch’s session ids: what the door treats as READABLE.


const BEDROCK_TOKEN_VAR: "AWS_BEARER_TOKEN_BEDROCK" = "AWS_BEARER_TOKEN_BEDROCK";

The variable whose presence decides whether live mode can run.


const CEILINGS: object;

The ceilings, restated from apps/consolidator/src/contract.ts so the corpus reads on its own.

readonly claim: 300 = 300;
readonly entities: 64 = 64;
readonly evidence: 32 = 32;
readonly gist: 1500 = 1500;
readonly quote: 600 = 600;

const DEFAULT_CORPUS_SIZE: 200 = 200;

How many base memories a default corpus carries, before controls and the archive tier.


const DEFAULT_PROBE_COUNT: 36 = 36;

How many probes a default corpus carries. Design §5 requires at least 30.


const DEFAULT_SEED: 20260802 = 20_260_802;

The default seed. Named so a caller changing it is making a visible choice.


const DIVERGENCE_FAMILIES: ReadonlyArray<DivergenceFamily>;

const FAKE_DIM: 1024 = EMBED_DIM;

The vector width the fake produces. It is the real one, so a width check cannot pass by luck.


const GATE_SOURCES: ReadonlyArray<{
source: string;
stage: GateStage;
}>;

The gate’s stage table as the manifest records it. One place, so the manifest cannot drift.


const GATE_STAGES: ReadonlyArray<GateStage>;

const KNOWN_GAP_CLASSES: ReadonlyArray<string>;

The failure classes the gate is KNOWN not to cover, as of the frozen manifest.

Stated in code so the eval can assert the gap is exactly this and no wider: a class that starts failing here is a regression, and a class that starts passing is a change the manifest should record. Three classes:

  • restatement/*: the TRACE-2 bar is checked as a COUNT of quotes, and nothing dedupes them, so the same line twice clears it.
  • content-leak/*: nothing compares a claim against its own evidence, so a claim that is a verbatim transcript span is written into the corpus as prose.
  • shape/padded-session-id: the door’s ungroundedEvidenceReason compares ids untrimmed while the phase trims them, so a real quote from a real session is refused for its whitespace. A GOOD item the gate rejects, which is the one false rejection on this corpus.

const MANIFEST_FILENAME: "write-path-discrimination.manifest.json" = "write-path-discrimination.manifest.json";

The frozen manifest’s file name, under packages/eval/fixtures/.

The PATH is the caller’s to resolve: the eval tier resolves it from its own location and scripts/freeze-write-path.mjs from its own. This module deliberately holds no module-relative URL resolution, because @memhtml/eval is bundled into the published binary and the packaging census (tests-integration/tests/packaging.test.ts) requires every run-time asset resolution in shipped source to be a declared, shipped asset. The manifest is a repo fixture, not a shipped one: no command reads it, so declaring it would ship a file nothing in the package uses.


const MANIFEST_SCHEMA_VERSION: 1 = 1;

const MANY_SPANS: ReadonlyArray<{
quote: string;
sessionId: string;
}>;

Distinct verbatim spans, enough for the evidence-count ceiling cases.

Twenty-six named spans plus windows cut from the long line. Every entry is a substring of its transcript, and the corpus test asserts that for each one.


const MRR_FLOOR: 0.85 = 0.85;

The MRR floor, from design §5. A gate below this admits a target that loses to one of its own negation-flipped twins on one probe in seven. An agent cannot trust that retrieval layer to answer with the right fact.


const PROBE_LIMIT: 40 = 40;

How many hits each probe requests.

Wide enough that a control’s rank is observable rather than truncated into null. A window of 10 would report an inversion and a merely-narrow miss identically, and the two need different fixes. The gate itself compares ranks, so widening the window can only make it stricter.


const SESSION_A: "session-a" = "session-a";

const SESSION_B: "session-b" = "session-b";

const SESSION_OUTSIDE_BATCH: "session-z" = "session-z";

A session id no transcript in the batch carries.


const TRANSCRIPT_A: FixtureTranscript;

const TRANSCRIPT_B: FixtureTranscript;

const TRANSCRIPTS: ReadonlyArray<FixtureTranscript>;

const WRITE_PATH_BASELINE_F1: 0.9333 = 0.9333;

The measured baseline this floor was derived from. Recorded so the derivation is checkable.


const WRITE_PATH_CORPUS: ReadonlyArray<LabeledCandidate>;

The whole labeled corpus: 31 good, 31 bad, in id order.


const WRITE_PATH_F1_FLOOR: number;

F1 floor: the baseline minus five points, rounded down to two decimals.

Five points and not zero, because the floor is the alarm for a class-sized regression and the manifest is the alarm for a one-item one; a floor pinned at the baseline would fire on every deliberate corpus edit and train the reader to refreeze without looking.

function acceptanceAgrees(decision): boolean;

True when the decision matches the label.

PairDecision

boolean


function acceptanceByClass(decisions): readonly AcceptanceClassBreakdown[];

Group by class, in first-seen order.

readonly PairDecision[]

readonly AcceptanceClassBreakdown[]


function admitCandidate(raw, batch): Effect<GateDecision>;

Run one candidate through the gate.

Never fails: a decision is the answer, and the only I/O (fabricatedQuoteReason re-reading a transcript) already folds an unreadable file into a refusal rather than an error.

unknown

GateBatch

Effect<GateDecision>


function agrees(decision): boolean;

True when the decision matches the label.

LabeledDecision

boolean


function articleFor(spec): string;

The article markup for a spec: the claim paragraph, the first body paragraph joined onto it, then the remaining paragraphs and the element kit.

Written here rather than left to renderTemplate’s claim/body path because the kits carry real markup, and renderTemplate escapes its body strings as text. That is correct for a tool parameter and wrong for a <dl>.

MemorySpec

string


function breakdownByClass(decisions): readonly ClassBreakdown[];

Group by class, in first-seen order, so the breakdown reads in corpus order.

readonly LabeledDecision[]

readonly ClassBreakdown[]


function buildCorpus(options): CorpusSpec;

The whole fixture specification.

The order is fixed as people, base, archive tier, then controls, so the generated tree is written in a stable order and two runs at one (seed, now) produce byte-identical files. now is REQUIRED rather than defaulted from a clock, because this module’s contract is purity: every caller threads its own run instant (the harness reads Effect’s Clock once), it is quantized to the UTC day, and it is recorded on the spec, so a failing run is reproducible by passing the reported now back in.

number

The run instant to anchor stamps behind, UTC millis.

number

number

number

CorpusSpec


function buildStack(options?): Effect<EvalStack, never, Scope>;

Build the whole stack inside a scope: generate the corpus, index it, and return retrieval over it.

Effect.acquireRelease owns the database, so the caller’s Effect.scoped closes the connection, and the fixture’s own cleanup removes the temp tree. Both matter in a CLI, because a command that leaked a database handle would keep a WAL file alive under /tmp for the life of the process.

StackOptions = {}

Effect<EvalStack, never, Scope>


function confusionOf(decisions): ConfusionMatrix;

The four cells from the decisions.

readonly LabeledDecision[]

ConfusionMatrix


function corpusSha256(corpus?): string;

The corpus hash. Plain JSON.stringify over static data with a fixed key order.

readonly LabeledCandidate[] = WRITE_PATH_CORPUS

string


function decideAcceptance(corpus?): readonly PairDecision[];

Run the corpus through the composed fold path. Pure.

readonly LabeledGroup[] = ACCEPTANCE_CORPUS

readonly PairDecision[]


function decideAll(batch, corpus?): Effect<readonly LabeledDecision[]>;

Run every labeled candidate through the gate, in corpus order.

GateBatch

readonly LabeledCandidate[] = WRITE_PATH_CORPUS

Effect<readonly LabeledDecision[]>


function deriveControl(
target,
family,
options?
): DerivedControl | undefined;

Derive one control from a target, or refuse.

Refusal, returned as undefined, is the behavior callers depend on. It has two causes, and each one would otherwise produce a control that LOOKS adversarial and is not:

  1. The family does not apply (no number to flip).
  2. The pair fails the family’s own predicate. For negation that means the target body already carried a marker, so the flip is invisible to the guard that defines the family.

A caller that ignores the refusal and ships the pair anyway gets a probe whose control is a paraphrase, and a gate built on paraphrases passes no matter how badly retrieval discriminates.

ClaimText

DivergenceFamily

VariantOptions

DerivedControl | undefined


function describeAcceptance(report): string;

A one-line summary: the numbers, then the first disagreement.

AcceptanceReport

string


function describeFailure(report): string;

A one-line summary of a failure, for stderr and for the sleep merge’s refusal log.

Names the first inversion rather than every one, because an operator needs a probe to reproduce and a thirty-line dump of a failing gate is a thirty-line dump nobody reads. The full list is on the report.

DiscriminationReport

string


function describeWritePath(report): string;

A one-line summary for stderr and a report subject: the four numbers, then the first disagreement.

WritePathReport

string


function discriminate(
retrieval,
probes,
options
): Effect<DiscriminationReport, StorageFailure>;

Run the suite and summarize it in one call.

RetrievalShape

readonly Probe[]

number

EvalMode

number

Effect<DiscriminationReport, StorageFailure>


function discriminationGate(options?): Effect<EvalOutcome, DiscriminationFailed, never>;

The gate, for MergeOptions.preMergeGate.

A failing gate FAILS this effect, which is what @memhtml/sleep’s merge reads to refuse. It wraps the gate in Effect.result and turns a failure into refusal: "gate-failed" with main never moving. The shape matters, because a version returning a boolean would let a caller forget to check it, and a refusable gate must not be optional at its call site.

EvalOptions = {}

Effect<EvalOutcome, DiscriminationFailed, never>


function failingEmbedder(): EvalEmbedder;

An embedder that always fails, for the lexical-floor scenario.

The failure travels through the ERROR channel as a typed ModelUnavailable rather than a throw. The floor only holds if retrieval can catch it, and a defect would kill the fiber instead of narrowing the search.

EvalEmbedder


function fakeEmbedder(): EvalEmbedder;

EvalEmbedder


function fakeVector(text): Float32Array;

The deterministic embedder, built as a hash-seeded bag of words, L2-normalized.

The same construction @memhtml/index and @memhtml/sleep use in their own harnesses. Two texts sharing vocabulary have a genuinely high cosine and two disjoint texts a low one, which makes a negation-flipped control a real adversary here instead of a random vector the arm trivially separates. A random fake would make the gate meaningless in the easy direction and a constant fake in the hard one.

string

Float32Array


function familyPredicate(family): (textA, textB) => boolean;

The predicate a family’s control must satisfy against its target.

DivergenceFamily

(textA, textB) => boolean


function hasBedrockCredentials(env?): boolean;

True when a Bedrock bearer token is present in the environment.

Readonly<Record<string, string | undefined>> = process.env

boolean


function impliedPairs(corpus?): readonly object[];

The oriented pairs the corpus implies, in group order, exactly as the phase would hand them to mergeCandidates: groupPairsFor over each group, with corpus order as the age order and dedupMergeTextFor as the veto’s texts. simFor is empty because no pair here was mined, so every similarity is the recall floor — the phase’s own value for a group pair it never scored.

A path named by two groups takes its age from its FIRST appearance, the way one corpus row has one offset however many groups a model puts it in. The labeled corpus never repeats a path; the role-guard cases the tests build do, and a last-wins map would re-age a keeper under them.

readonly LabeledGroup[] = ACCEPTANCE_CORPUS

readonly object[]


function liveEmbedder(): Effect<EvalEmbedder>;

The real Bedrock embedder, for live mode. Built only when a caller asks for it.

Effect<EvalEmbedder>


function makeFixtureCorpus(options?): Effect<FixtureCorpus>;

A scaffolded memory repo carrying a generated corpus.

initRepo is the real one, so the fixture carries the real .gitignore, the real .gitattributes, and the real merge.ours.driver config. That config is per-clone, and the merge=ours attribute does nothing without it.

FixtureOptions = {}

Effect<FixtureCorpus>


function manifestFor(report, context): WritePathManifest;

Build the manifest a run would freeze.

WritePathReport

string

string

WritePathManifest


function memoryFileFor(spec): string;

One spec as a memory file’s bytes.

Hand-assembled rather than routed through @memhtml/html’s renderTemplate, because of the element kits. renderTemplate escapes each body string as TEXT, which is right for an agent’s tool parameter and wrong for a <dl> the fixture means as markup. The head is written in META_ORDER so a generated file is byte-identical to what the serializer would emit for the same metadata. The rest of the system then treats the fixture as an ordinary document.

MemorySpec

string


function negationFlip(claim): string;

The affirmative claim as its negation.

Total. When no verb anchor is present the whole sentence is wrapped rather than left unchanged. A transform that returned its input would produce a “control” identical to its target, and an identical control is a duplicate the content-hash index refuses at index time, one layer too late to explain itself.

string

string


function numericFlip(claim): string | undefined;

The claim with its first numeric token replaced by a different one.

undefined when the claim carries no number, because the family does not apply. Inventing a number to flip would produce a control that differs from its target by an ADDED fact rather than by a contradicted one. The probe builder reads the undefined and skips the family instead of emitting a weaker control under the same name.

The replacement is value + 10 for a small integer and value * 2 otherwise, so the wrong number stays in the plausible range for whatever the sentence counts. A retry budget of 13 is a believable misremembering of 3, and 3000 is not.

string

string | undefined


function padTo(text, length): string;

Pad a sentence to EXACTLY length characters with prose filler, for the at-the-ceiling cases.

The filler is words, so the result still slugs to a title and still reads as a sentence, and the last character is forced to a letter so a boundary case cannot end in a space the door might trim.

string

number

string


function quantizeNow(nowMillis): number;

Quantize a run instant to its UTC day.

The anchor is the day rather than the millisecond so that every run on one calendar date builds a byte-identical corpus at a given seed. The gate’s determinism checks compare two full runs, and a millisecond-precision anchor would make “same seed, same numbers” true only for runs started in the same millisecond.

Math.floor rather than a % remainder, because JS % keeps the DIVIDEND’s sign: at nowMillis = -1, nowMillis % DAY_MILLIS is -1 and subtracting it lands on 0 — an anchor AHEAD of the instant asked for, which is the one thing the anchoring exists to rule out.

A non-finite instant is refused here rather than carried: NaN passes every arithmetic step and surfaces hundreds of lines later as RangeError: Invalid time value out of a stamp formatter, which names neither the input nor the caller that supplied it.

number

number


function queryFor(spec, limit?): string;

A probe query holds the target’s own content words, in order, capped.

Content words rather than the claim verbatim, and the query design decides what the probe can measure. A NUMERIC-family control differs from its target in exactly one numeric token, so a query that dropped the number would have identical overlap with both and the vector arm could not order them at all. The probe would measure a tie-break instead. Keeping the digits is what makes the numeric family a probe rather than a coin toss. The same reasoning keeps a variant qualifier’s ANCHOR in the query while the qualifier itself, which only the control carries, stays out.

Stopwords go so that not cannot hide behind them. A query carrying the target’s function words would raise the negation control’s lexical overlap for a reason unrelated to the fact either states.

MemorySpec

number = 12

string


function readManifest(path): Effect<WritePathManifest>;

Read a frozen manifest from path.

string | URL

Effect<WritePathManifest>


function replayDrift(frozen, fresh): readonly ReplayDrift[];

Every id whose fresh decision differs from the frozen one, plus ids present on one side only.

Per item rather than a hash of the whole list, because a replay that fails has to say WHICH candidate moved and where it now stops; a boolean would send the reader back to run it by hand.

WritePathManifest

WritePathReport

readonly ReplayDrift[]


function runDiscrimination(options?): Effect<EvalOutcome, never, never>;

Run the gate.

Never fails, because the report IS the answer and a caller maps passed to an exit code. An eval whose error channel could fire would hand a caller a run that both happened and errored, with no way to say whether the corpus, the index, or the ranking was at fault.

EvalOptions = {}

Effect<EvalOutcome, never, never>


function runFloor(
retrieval,
probes,
options?
): Effect<FloorReport, StorageFailure>;

RetrievalShape

readonly Probe[]

number

Effect<FloorReport, StorageFailure>


function runProbes(
retrieval,
probes,
options?
): Effect<readonly ProbeResult[], StorageFailure>;

Run every probe through the real retrieval service.

includeArchived stays false. The corpus carries an archived tier and an archived memory must not be a candidate, so a probe that ranked one would be reporting a scope leak rather than a ranking failure. The controls are ACTIVE files, which is what makes them adversaries.

RetrievalShape

readonly Probe[]

number

Effect<readonly ProbeResult[], StorageFailure>


function runWritePathAcceptance(options?): AcceptanceReport;

Run the arm end to end. Pure and total: the report is the answer.

AcceptanceOptions = {}

AcceptanceReport


function runWritePathDiscrimination(options?): Effect<WritePathReport>;

Run the arm end to end.

Never fails, for the reason run.ts gives for the retrieval arm: the report IS the answer and a caller maps passed to an exit code or an assertion. A failing report logs one line at error level.

WritePathOptions = {}

Effect<WritePathReport>


function summarize(
mode,
results,
mrrFloor?
): DiscriminationReport;

Aggregate probe results into the report the gate reads.

An empty suite is a FAILURE, not a vacuous pass. Zero probes yields mrr: 0, which is below any floor, so a corpus whose probe generation produced nothing refuses instead of reporting a green gate over no measurement. A skipped quality gate must not look like a passing one, and “no probes ran” is the purest form of skipped.

EvalMode

readonly ProbeResult[]

number = MRR_FLOOR

DiscriminationReport


function summarizeAcceptance(
decisions,
acceptanceFloor,
groups
): AcceptanceReport;

Aggregate the decisions into the report the gate reads.

readonly PairDecision[]

number

number

AcceptanceReport


function summarizeWritePath(decisions, f1Floor): WritePathReport;

Aggregate the decisions into the report the gate reads.

readonly LabeledDecision[]

number

WritePathReport


function transcriptsSha256(transcripts?): string;

readonly FixtureTranscript[] = TRANSCRIPTS

string


function variantFlip(
claim,
anchor,
qualifier
): string;

The claim with a variant qualifier inserted after anchor.

qualifier must be a token @memhtml/domain’s VARIANT_QUALIFIERS knows, or the pair is not variant-divergent and deriveControl refuses it. Total, so an absent anchor appends a scope sentence naming the qualifier. That states a different fact about a different variant instead of a paraphrase.

string

string

string

string


function wholeText(text): string;

The whole text one memory contributes, claim first. What the veto predicates compare.

ClaimText

string


function withBatch<A, E, R>(body, transcripts?): Effect<A, E, R>;

Write the transcripts into a fresh temp directory and hand back the batch the door reads.

fabricatedQuoteReason re-reads a cited transcript from its host path, which is the production shape (the phase never holds transcript bytes), so the fixture has to be on disk. Scoped: the directory is removed when the caller’s scope closes, on the failure path too.

A

E

R

(batch) => Effect<A, E, R>

readonly FixtureTranscript[] = TRANSCRIPTS

Effect<A, E, R>


function withStack<A, E, R>(body, options?): Effect<A, E, R>;

Build the stack, run body, then tear both down.

A scoped helper rather than a fixture the caller assembles, so the temp tree is removed on the failure path too. An eval that exits 1 on an inversion must not leave a 200-file corpus under /tmp every time the gate refuses.

A

E

R

(stack) => Effect<A, E, R>

StackOptions = {}

Effect<A, E, R>


function writeCorpus(
root,
git,
spec
): Effect<number>;

Generate the corpus into root, committing it.

ONE commit for the whole corpus. A commit per memory would make the fixture’s git history the dominant cost of every eval run. The gate is about ranking, and git log over a generated corpus tells a reader nothing.

string

GitShape

CorpusSpec

Effect<number>


function writeManifest(manifest, path): Effect<void>;

Write a frozen manifest to path. Only scripts/freeze-write-path.mjs calls this.

WritePathManifest

string | URL

Effect<void>