asterism branch T-1910/bugfix-m4-fixture-work-matching commits 2 files 13 touched lines +950 / -49 tests 2,358 passed (181 s)

Pre-push review: T-1910/bugfix-m4-fixture-work-matching

PR #65 — the M4 preview budget gains Work-bearing fixture bases and four budget arms; candidate validation and bucketing are hoisted out of the per-Entry loop (IdentityFirstWorkCandidateIndex, WorkMatchIndex). Identity-matching complete preview 5.82 s → 0.19 s against a 1 s budget. Reviewed against origin/main (merge base 30c1573). No fixes were applied: the review ran under a no-modification constraint.

At a glance

  • Equivalence holds. WorkMatcher, identity-first title fallback, identity reuse, and claim arms all give the scan's answer through the index, .ambiguous ordering included. Blank-input guards run before candidate validation on every array-taking entry point (round-2 fix), pinned by blankInputsBeforeCandidateValidation.
  • Eager validation is narrower than the report says. deriveURLIdentity already validated identity tuples and duplicate ids eagerly; evidence from a real basis cannot fail validateEvidence. The single new refusal is a blank-display-title Work on an identity-bearing Site where every Entry is .protected/.noChange. The validator diagnoses that Work, but since library-integrity-tolerance a diagnosed library still reaches teaching, so the user now sees "Unable to generate preview" there.
  • Dead work on the keystroke path. titleIndex is built for every projection but WorkMatcher.match(index:) is unreachable when identityBearing is true — every case of the identity-first switch returns first. That is most of the 1.6 ms identity edit-ack regression.
  • Reachability pin is weaker than claimed. identityMatchBasisReusesEveryWork asserts projectedWorkID ∈ basis.works; the Works share titles with the title arm, so a nil derivedWorkIdentity would produce the same ids via title fallback and the test would still pass. Asserting assignment == .reuse(workID:) per Entry or derivedWorkIdentity != nil closes it.
  • Docs: 32/28 test counts, eight known issues, and every ratio verified. Inconsistencies: "~21 minutes" vs 1,380 s + 190 s ≈ 26 min; "neither is in AsterismCore" (they are in the package's AsterismIntelligenceTests target, which make test-core runs); 5.81 s in the code comment vs 5.82 s everywhere else; unified-teaching-composition/implementation.md:224-227 still says the fixture is untested for Work matching.
  • Verification: make verify-identity + swift test --no-parallel over AsterismCore: 2,358 tests in 230 suites passed in 181 s; coverage exported; working tree unchanged. make test-performance-m4 deliberately not run.

Verdict

Ready to push

No blockers, no majors. The indexed matching paths were checked arm by arm against the removed scan code and are outcome-identical, including .ambiguous member order and thrown-error identity and order: ExactScalarString hashes and compares on the same scalar sequence scalarEqual used, buckets are pre-sorted with the same comparator the scans applied post-filter, and validate guarantees unique ids so the order is total. The eager-validation change is real but narrow — the only newly-refused shape is an identity-bearing Site with a blank-title Work and no Entry reaching a matcher — and LibraryValidator already diagnoses that Work. The perf-doc claims check out: 32 tests (14+6+5+4+3) in the five suites the Makefile regex selects, 28 before; eight known issues unchanged; every ratio in measurements.md recomputes. make test-core equivalent run passed (2,358 tests, exit 0). What remains is a set of minors worth a short follow-up commit before merge rather than a blocker: the title index is dead work on identity-bearing projections, the identity reachability test would also pass on title fallback, the two 5,000-Entry reachability tests add ~20 s to make test-core, and CLAUDE.md's "~21 minutes" no longer reconciles with the 1,380 s it now cites (from a loaded-host run that exited non-zero). The owed quiet-host make test-performance-m4 run is the owner's and was not run here.

Review findings

18 raised · 0 fixed · 18 skipped

Jump to findings →

Tests

Pass rate: 100% (2323 of 2323)

New tests: 11

Diff coverage: 89% (317 of 356 added lines)

Jump to tests →

Commits

Three-level explanation

What changed

Asterism has a performance test that checks how long the app takes to "preview" a teaching change across a big pretend library: 5,000 saved pages (Entries) on one website. Part of that preview is matching every page to the story (Work) it belongs to. The old pretend library had zero stories in it, so the matching step had nothing to do and the test was fast for the wrong reason. This change adds two new pretend libraries that do contain 1,000 stories — one where pages are matched by title, one where they are matched by a URL identity — and adds timing tests over them.

Once the real matching was measured, one shape took 5.8 seconds against a 1-second limit. The cause: for each of the 5,000 pages, the matcher re-checked and re-scanned the entire list of 1,000 stories from scratch. The fix builds a lookup table (an "index") of the stories once, then each page does a quick dictionary lookup instead of a scan. That brought it down to 0.19 seconds.

Why it matters

This preview runs on every keystroke while the reader is editing a teaching rule. A 5.8-second stall there would be a visibly frozen screen. Just as importantly, the project had a written promise (a "budget") that this would be fast, and the test guarding the promise was not actually testing it.

Key concepts

  • Fixture: fake data built just for a test. Like a crash-test dummy — if the dummy is missing its head, the seatbelt test proves nothing about heads.
  • Budget: a stated time limit a test asserts (edit acknowledgement ≤ 100 ms, full preview ≤ 1 s).
  • Index: a pre-built lookup table keyed by the thing you search for. Building it costs a little up front; every later lookup is nearly free.
  • Loop-invariant work: work whose answer cannot change between iterations of a loop, so it should be done once outside the loop.

Changes overview

  • M4ScaleFixture.swift: new titleMatchBasis(entryCount:) and identityMatchBasis(entryCount:) build a taught Site with all 1,000 Works (whatever the Entry count) plus an identity-bearing URL rule (.workAndSequence over a story query slug). Entries carry .pattern provenance so assign does not short-circuit to .protected. The existing composedBasis is untouched so banded measurements do not move.
  • M4ScalePerformanceTests.swift: four new arms reuse the existing Req 8.5 budgets over those bases. M4ScaleFixtureTests.swift: four correctness tests that run in make test-core and pin that the bases actually reach the matching arms (all 1,000 Works reused, no prospective Works).
  • URLIdentityPlanner.swift: IdentityFirstWorkCandidateIndex validates once and buckets candidates by exact matching title and by retained rule identity, each bucket pre-sorted with candidateOrder. match and identityReuse gain index-taking overloads; the array-taking versions become thin wrappers that build a single-use index after running the blank-input guards.
  • TeachingTypes.swift: WorkMatchIndex and WorkMatcher.match(parsedWorkTitle:index:); matchingTitle(of:) extracted so scan and index share one definition. The array-taking scan is kept as-is for one-shot callers.
  • ComposedTeachingProjection.swift: project builds both indices once before the per-Entry loop; assign takes them.

Implementation approach

The key insight is that the dictionary key type must carry the same equality the scan used. ExactScalarString compares and hashes on Unicode scalars, so a dictionary keyed on it is exactly scalarEqual; keying on String would have silently moved the matcher to canonical equivalence (the Cafe\u{0301} test case exists for this). Buckets are pre-sorted with the same comparator the scan applied post-filter, so an .ambiguous read from a bucket reports its members in the same order. The identity bucket keys only on the tuple half of isIdentityMatch; the evidence half is still filtered per candidate, and filter preserves the pre-sorted order.

Trade-offs

  • Building an index for a single-Entry projection costs more than one scan: the edit-ack arms rose from 0.29 → 0.70 ms and 2.8 → 4.4 ms, against a 100 ms budget. Accepted in exchange for 5.8 s → 0.19 s on the full preview.
  • Validation is now eager (once, before the loop) rather than lazy (inside the first matcher call). A library with an invalid Work and only protected Entries now throws where it used to project. Every such shape is already rejected by LibraryValidator (blank display title, invalid identity tuple), so no valid library is affected.
  • TitleProjectionPlanner has the same per-Entry scan shape but is left alone — nothing budgets it and there is no failing test to anchor the change.

Technical deep dive

Equivalence argument, arm by arm. (1) WorkMatcher: scan predicate was scalarEqual(matchingTitle, parsed) = unicodeScalars.elementsEqual; index key is ExactScalarString whose == is the same elementsEqual and whose hash(into:) combines scalar count plus each scalar value, so hash-consistency with == holds. Count 0/1/n branches and the UUID-string sort are identical. (2) Identity-first title fallback and claim path: the old code filtered on matchingTitle == parsedTitle then sorted by candidateOrder; the bucket is the same filter result pre-sorted, and isClaimEligible is applied by an order-preserving filter. (3) identityMatchOutcome: old predicate required state == .rule && value == identity (an Optional<ExactScalarString> comparison, so nil never matched) plus the evidence check; the byRetainedRuleIdentity bucket admits exactly state == .rule with non-nil value, and isIdentityMatch is re-applied. No candidate can be in one set and not the other. candidateOrder is a total order because validate has already rejected duplicate ids, so sort instability cannot reorder ties.

Error equivalence. The array-taking wrappers run requireNonBlank before constructing the index (which runs validate), preserving the previous guard order — the round-2 fix. The index-taking match re-runs requireNonBlank, so the array path evaluates it twice; harmless (two isBlank scans) but redundant.

Eager validation. The lazy path only ever skipped validation when no Entry reached a matcher: every Entry .protected (manual provenance or intentionally unattached), or every Entry returning .noChange for want of a name with no extracted identity. For a real library identityFirstCandidates derives matchingTitle from lastParsedTitle/displayTitle (blank only if displayTitle is blank, which LibraryValidator.validate(work:) rejects), previousIdentity from the membership tuple (whose validity the validator also checks), and evidence from deriveURLIdentity or the self-consistent .noEntries(previousIdentity:) fallback. So the new refusal set is contained in the validator's refusal set; a Site that reaches teaching has already passed per-Site load validation (unified-teaching-composition Q10).

Architecture impact

Two new public value types on the AsterismCore surface, both Sendable, both immutable after init. validate and candidateOrder widened from private to fileprivate so the index (same file) can share them — a modest coupling, contained within the file. The bucket accessors are fileprivate, so the index is opaque outside its file; consumers can only pass it back to the planner. The pattern (validate-once index + thin array wrappers) is directly reusable for TitleProjectionPlanner.

Potential issues

  • Unconditional index construction in project: the title index is built even on identity-bearing Sites where it is never consulted for Entries that reuse by identity. Cost is one pass over basis.works; measured within budget.
  • IdentityFirstWorkCandidateIndex.candidates is retained only for a debug log count — a full array reference held for a number.
  • The two M4ScaleFixtureTests reachability tests add roughly 20 s of debug time to make test-core (5.0 s + 15.4 s per measurements.md §5).
  • The one make test-performance-m4 run on the branch failed a regression ceiling on an untouched arm under host load 13–29; the PR owes one quiet-host run, which the owner runs before merge.
  • CLAUDE.md now cites 1,380 s of test time while still headlining "~21 minutes"; 1,380 s + 190 s build ≈ 26 min, though the 1,380 s was measured on a loaded host.

Important changes — detailed

URLIdentityPlanner: IdentityFirstWorkCandidateIndex — validate once, bucket twice

Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift

Why it matters. This is the correctness-critical piece: the whole 5.8 s → 0.19 s win rests on the buckets giving the scan's exact answer. The identity bucket keys on the tuple half of isIdentityMatch (state == .rule, non-nil value) and re-applies the full predicate; the title bucket feeds both the nil-identity fallback and the claim path. Buckets are pre-sorted with candidateOrder so an .ambiguous read from a bucket reports the scan's order.

What to look at. URLIdentityPlanner.swift:340-398 (index), 428-510 (index-taking overloads and requireNonBlank)

Takeaway. When hoisting a validate-and-scan out of a loop, key the index on the type that carries the loop's equality (here ExactScalarString, scalar-exact) and pre-sort buckets with the comparator the scan applied after filtering. Then filter preserves order and the index is provably the scan. Keep the array-taking API as a thin wrapper that builds a single-use index so one-shot callers keep their error contract.
Rationale. Per the bugfix report: memoising validation inside the planner (a static cache keyed on the array) was rejected as shared mutable state that would hash the whole array anyway; separate index type plus overloads keeps the planner pure and every existing caller's behaviour and errors intact.

TeachingTypes: WorkMatchIndex and the shared matchingTitle(of:) rule

Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift

Why it matters. The title arm's per-Entry scan (0.51 s → 0.08 s). The array-taking match deliberately keeps its scan; matchingTitle(of:) is extracted so scan and index share one definition of which title a candidate matches on.

What to look at. TeachingTypes.swift:169-248

Takeaway. Keying a dictionary on String would silently switch a scalar-exact matcher to canonical equivalence (the Cafe\u{0301} test exists for this). Wrap the key in the type that owns the equality.
Rationale. Code comment: a one-shot match pays only a comparison per candidate, while building an index costs a hash and an allocation per candidate — so the array-taking form stays a scan and the index is for callers matching many titles against one list.

ComposedTeachingProjection: build both indices once; validation becomes eager

Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift

Why it matters. The hot path. Also the one deliberate behaviour change: IdentityFirstWorkCandidateIndex(validating:) throws before the loop, so an invalid Work list is refused even when no Entry would have reached a matcher. Note titleIndex is built unconditionally although it is unreachable when identityBearing is true.

What to look at. ComposedTeachingProjection.swift:457-473 (indices), 634-672 (assign)

Takeaway. Eager validation makes refusal independent of which Entries happen to reach an arm — more deterministic — but it widens the set of inputs that throw. Enumerate that set before accepting it; here it is one shape and the validator already diagnoses it.
Rationale. Report: 'the stricter and more deterministic answer — the refusal no longer depends on which Entries happen to reach the arm — and every shape it refuses is one the library validator already rejects.' The review confirms the shape enumeration; the validator rejects it but does not block teaching on a diagnosed library, so the user-visible effect is a preview error where there was a projection.

M4ScaleFixture: Work-bearing re-teaching bases

Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift

Why it matters. The reported bug. titleMatchBasis and identityMatchBasis carry all 1,000 Works whatever the Entry count, use .pattern provenance so assign does not short-circuit to .protected, and cite one shared rule UUID between the URL rule basis and the Works' identities. composedBasis is untouched so banded measurements do not move.

What to look at. M4ScaleFixture.swift:75-191

Takeaway. A performance fixture is part of the assertion. When a budget guards a code path, something must pin that the fixture reaches the path — assert reachability (candidate counts, assignment kinds), not only timings.
Rationale. Report: changing composedBasis was rejected because several suites measure over it and their bands are recorded in CLAUDE.md and the verification-run files; separate members keep the initial-teaching shape those bands were drawn against.

Performance and fixture tests: four budget arms, four reachability pins

Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift

Why it matters. The four arms reuse the Req 8.5 budgets over the new bases (basis built outside the timed closure, confirmed). The reachability tests run in make test-core; two of them project 5,000 Entries in debug (5.0 s and 15.4 s) and the identity one cannot distinguish reuse-by-identity from title fallback.

What to look at. M4ScalePerformanceTests.swift:101-156; M4ScaleFixtureTests.swift:58-121

Takeaway. A reachability pin does not need the performance fixture's full size — a count that still touches every arm (a few Works' worth of Entries) keeps the pre-commit bar fast and leaves the 5,000 shape to the performance target.
Rationale. The 5,000 count mirrors the performance arms so the correctness test exercises the same shape the budget measures. (inferred — not stated by the author)

Docs: CLAUDE.md, testing.md, measurements.md, report.md

CLAUDE.md

Why it matters. CLAUDE.md is the load-bearing description of make test-performance-m4. The new numbers (1,380 s, 32 tests) are correct as counts but come from a loaded-host run that exited non-zero, cited in the sentence that says the target exits 0; '~21 minutes' no longer matches the arithmetic.

What to look at. CLAUDE.md:61; docs/agent-notes/testing.md:220-224, 269-278; specs/bugfixes/m4-fixture-work-matching/measurements.md, report.md

Takeaway. Record a duration band only from the run that the doc's own claim ('exits 0') describes; a loaded-host measurement belongs in the measurements file with its caveat, not in the headline.
Rationale. The doc bullet accumulates one measurement per feature that changes the target, so the author added this PR's run in the same style. (inferred — not stated by the author)

Key decisions

Add separate Work-bearing bases rather than giving composedBasis Works.

Several suites measure over composedBasis and their bands are recorded in CLAUDE.md and the verification-run files; adding Works there would shift every one at once. New members keep the initial-teaching shape those bands were drawn against. (report.md, Alternatives.)

Optimise the planner rather than land the fixture alone.

The ticket's stated minimum was the fixture. Fixture-only would leave make test-performance-m4 red, and that target's contract is exit 0 with accepted breaches as known issues; a 5.8× breach on a per-keystroke path is not an accepted breach. (report.md.)

Index type plus index-taking overloads, not memoised validation.

A static cache keyed on the candidate array was rejected as shared mutable state in a pure planner whose key would hash the whole array anyway. The array-taking entry points became thin wrappers over a single-use index so every existing caller keeps its behaviour and error cases. (report.md.)

Key the indices on ExactScalarString.

Its == and hash(into:) both work on Unicode scalars — the equality the scan applied. Keying on String would silently switch the matcher to canonical equivalence. (Code comment on WorkMatchIndex; report.md.)

The array-taking WorkMatcher.match keeps its scan.

A one-shot match pays one comparison per candidate; building an index for it would cost a hash and an allocation per candidate. The index is for callers matching many titles against one list. (Code comment.)

Accept eager candidate validation in project.

Validating once before the loop is stricter and more deterministic — refusal no longer depends on which Entries reach an arm — and every refused shape is one LibraryValidator already diagnoses. Pinned by invalidWorksRefusedWithProtectedEntries. (report.md.) The review adds: the validator diagnoses but does not block teaching on a diagnosed library, so the visible effect is a preview error rather than a projection; and this is recorded only in the report, not in a source comment or a decision log.

Blank-input guards run before candidate validation on the array-taking paths.

Round-2 fix: requireNonBlank is called in the wrapper before the index is built, so a blank title or identity is reported as such even when the candidate array is also invalid — the order the old code had. (Commit cb4d92c; code comment; blankInputsBeforeCandidateValidation.)

TitleProjectionPlanner's per-Entry scan is out of scope.

Nothing budgets it and there is no failing test to anchor a change. The review confirms it is not on a budgeted path (no M4 performance test references it) and that its inout candidates is never mutated, so a future WorkMatchIndex hoist is mechanical. (report.md, Not Done.)

Reachability tests project the full 5,000 Entries.

Not stated. Presumably to mirror the performance arms' shape. The assertions do not need that count; see finding on make test-core cost.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorComposedTeachingProjection.swift:469 titleIndexThe title index is built for every projection, but WorkMatcher.match(index:) is unreachable when identityBearing is true — every case of the identity-first switch in assign returns. On identity-bearing Sites the 1,000 hashes and two dictionary passes are dead work on the keystroke path (most of the 1.6 ms identity edit-ack regression).Gate it as identityIndex already is: WorkMatchIndex(candidates: identityBearing ? [] : basis.works.map { … }). Not applied (no-modification review); left for the author.
minorM4ScaleFixtureTests.swift:88 identityMatchBasisReusesEveryWorkAsserts projectedWorkID ∈ basis.works, but the identity basis's Works carry the same titles as the title arm would match, so a nil derivedWorkIdentity (title fallback) yields the same ids and the test still passes. The 'pins that the bases reach the arms' claim in testing.md and the report is therefore weaker than stated for the identity arm.Assert per-Entry assignment == .reuse(workID: m4FixtureUUID(namespace: 10, index: i / 5)) (assignment is public) and/or derivedWorkIdentity != nil. Left for the author.
minorURLIdentityPlannerTests.swift — index-path .ambiguous orderingindexMatchesScan pins WorkMatcher only. On the identity-first planner, the identity arm's order is pinned by the existing identityReuseAndAmbiguity (candidates supplied [second, first], expects [first, second], now routed through the index), but the nil-identity title-fallback and claim arms have no two-candidate .ambiguous test anywhere — and this PR moved their ordering from a per-call sort to a bucket pre-sort.One test with two same-title candidates in reverse UUID order asserting match(candidates:) and match(index:) agree and equal [low, high] on both arms. Left for the author.
minorM4ScaleFixtureTests.swift:71,88 — make test-core costThe two reachability tests project 5,000 Entries in debug (5.0 s and 15.4 s per measurements.md §5) inside a .serialized suite, adding ~20 s to the pre-commit bar. None of their assertions needs that count (works.count == workCount is driven by basis.works; prospectiveWorks.isEmpty and projectedWorkID membership hold at any count). Precedent in the same file uses 1 and 100.Use a small count (e.g. m4FixtureEntriesPerWork * 2) and assert the distinct reused-Work count equals entryCount / m4FixtureEntriesPerWork. Left for the author. Also worth a look on a quiet host: debug/release ratio is ~4× for the Work-free basis but ~60–80× for the new arms — plausibly ExactScalarString scalar hashing under -Onone, or host noise.
minorCLAUDE.md:61; docs/agent-notes/testing.md:220-224'~21 minutes' now sits beside '1,380 s … plus a ~190 s release build' (≈ 26 min). The 1,380 s comes from a run measurements.md §3 reports as failed with 9 issues, on a host at load 13–29, cited in the sentence asserting 'The target exits 0'. The previous 1,120 s + 190 s did reconcile with 21 min.Either hold the wall-clock figure until the owed quiet-host run, or keep 1,380 s labelled 'on a loaded host' and widen to '~21–26 minutes'. Left for the author.
minorCLAUDE.md:61 pointer'See specs/work-and-reading-status/verification-run.md §4 for the current numbers' is still right for the 28 pre-existing arms but that file has no rows for the four new arms; their only numbers are in specs/bugfixes/m4-fixture-work-matching/measurements.md §1.Append a pointer to measurements.md §1 for the T-1910 arms. Left for the author.
minormeasurements.md §5; report.md Verification'neither is in AsterismCore' is wrong as written: the two live Apple Intelligence suites are in the AsterismCore package's AsterismIntelligenceTests target, and make test-core (a bare swift test --package-path Packages/AsterismCore) runs them — which is exactly why the re-run exited 2. CLAUDE.md already documents this.Reword to 'in the AsterismIntelligenceTests target, not AsterismCoreTests; make test-core runs the whole package'. Left for the author.
minorspecs/unified-teaching-composition/implementation.md:224-227'The 5,000-Entry budget fixture is built with works: [] … the untested-for-performance case' is now false and is the exact finding the ticket came from.Strike-through plus 'Resolved by T-1910, specs/bugfixes/m4-fixture-work-matching/', the convention testing.md uses for corrected claims. Left for the author.
minorEager validation — where it is recordedThe behaviour change is described in report.md and pinned by a test, but the project comment (ComposedTeachingProjection.swift:461-466) explains only the hoist, and no decision log carries it. Repo precedent records bugfix decisions in the parent feature's log (e.g. teaching-change-detection → library-integrity-tolerance Decision 12). Also, 'the library validator already rejects' should not be read as 'already blocks teaching': since library-integrity-tolerance a diagnosed library still reaches projectComposedTeaching, so the user now sees 'Unable to generate preview. Library unchanged.' (and RuleSuggester fails) on that library.One sentence in the project comment; a Quick Decisions row or full entry in specs/unified-teaching-composition/decision_log.md cited from the report. Left for the author.
minorWorkMatcher.matchingTitle(of:) vs two pre-existing copiesThe 'lastParsedTitle if nonblank else displayTitle' rule now has a named home (TeachingTypes.swift:243-248) but identityFirstCandidates (ComposedTeachingProjection.swift:623-625) and captureWorkMatch (LibraryRepository+OutcomeComputation.swift:177-180) still hand-roll it; both take a ComposedWorkBasis so cannot call the candidate-typed helper as signed. Req 6.3's capture-reproduces-the-sweep guarantee depends on the three agreeing.Make the helper field-based — matchingTitle(lastParsedTitle:displayTitle:) — with the candidate form forwarding, and call it from both copies. Left for the author.
minorM4ScaleFixture.swift:195-218 captureBasis()captureBasis() now duplicates the PR's own private helpers byte-for-byte: its titleRule literal is titleRuleBasis, its urlRule literal is urlRuleBasis(m4FixtureURLDefinition()) with the same namespace-2 UUID as urlRuleID, and its works loop equals workBasis(workIndex:identityBearing: false). The workCount doc comment ('The number of Works the taught capture basis carries') is now stale — the new bases use it too.Have captureBasis() call the three helpers (basis construction is outside the timed closure, so the capture band is unaffected) and update the comment. Left for the author.
minorURLIdentityPlanner.swift:357 IdentityFirstWorkCandidateIndex.candidatesA public let array retained solely so match(index:) can log index.candidates.count. It is a second reference to the list the index exists to stop scanning, and it is public API. COW-shared, so no memory cost.Replace with a fileprivate candidateCount: Int, or drop the count from the log line. Left for the author.
nitNumber consistency across code comment and docsComposedTeachingProjection.swift:463 says 5.81 s; report.md, testing.md and measurements.md say 5.82 s (reproductions 5.813 s and 5.823 s). measurements.md: 'a 0.4 ms regression' covers only the title arm (identity moved +1.6 ms); 'rose 15–35%' is 17% and 38% from its own table; the control column's '—' for capture-projection-duplicateIdentity is unexplained; report.md's capture '0.0002 s measured' has no run cited (recorded band is ~0.07 ms).Pick 5.8 s or 5.82 s everywhere; tighten the three phrasings; cite or drop the capture figure. Left for the author.
nitM4ScaleFixture.swift:77-80 urlRuleID doc'both sides have to name the same UUID' overstates: nothing cross-checks a Work's rule reference against the basis's rule id (isIdentityMatch compares values; isValid needs only a rule reference). Sharing the UUID is the realistic shape, not a requirement.Reword to 'cites the same rule row the basis carries, as a real taught Site would'. Left for the author.
nitURLIdentityPlanner.swift:374-379, 439/455Two identical mapValues { $0.count == 1 ? $0 : $0.sorted(by: candidateOrder) } blocks; requireNonBlank runs twice on the array-taking match (wrapper, then the index-taking form). Both documented, both negligible; the array-taking forms now have no production callers.Optional fileprivate sortedBuckets helper. Not worth acting on.
nitTitleProjectionPlanner.swift:160, 385, 412Out of scope per the report and defensibly so (not on a budgeted path; its only app reach is the UI-test fixture seeder). But its inout candidates is never mutated and the comment 'mutable list … that includes newly created Works' is stale — a WorkMatchIndex hoist there is mechanical.Follow-up ticket; note the stale comment in it. Not for this PR.
nitURLIdentityTypes.swift:34-39 ExactScalarString.hash(into:)Does an O(n) unicodeScalars.count pre-pass plus one combine per scalar. Hashing value.utf8 bytes is equivalent (UTF-8 is injective on scalar sequences) and roughly 5–10× cheaper, but the total here is ~1–2 ms of the 190 ms arm and the type keys several other dictionaries.General follow-up, not a T-1910 change.
nitBucket construction styleManual [:] + default: [] loops rather than Dictionary(grouping:by:) + mapValues(sorted), which IdentityResolution.swift:70 uses. The manual form also has precedent (URLIdentityPlanner.swift:876, :907) and the identity index fills two dictionaries in one pass.Style only. Not worth acting on.

Tests

Source: local run at 2026-09-05T17:21:00+10:00 · snapshot cb4d92ce6a3320b3cee81c1e900834c6ead57599

Baseline: none

Execution: passed · JUnit: 1 file · Coverage: 1 file · Baseline: absent

Coverage scope: every test in the repository

Totals: 2323 passed · 0 failed · 35 skipped · 0 errored · 0 flaky

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Diff coverage

FileAdded linesCoveredDiff coverage
Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift1145895%
Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift6232100%
Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift1716100%
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift13673100%
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift6551100%
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift5700%
Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift3836100%
Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift2826100%
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift2725100%
CLAUDE.md1no coverage data
docs/agent-notes/testing.md12no coverage data
specs/bugfixes/m4-fixture-work-matching/report.md245no coverage data
specs/bugfixes/m4-fixture-work-matching/measurements.md148no coverage data

Aggregate diff coverage: 89% (317 of 356 measurable added lines).

Overall coverage

Head 93.7% (77934 of 83141 lines)

9 of 13 changed files matched coverage data.

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 30c1573050a3432567c9cbdf96d94eab46194f0f.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift Modified +114 / -24
diff --git a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swiftindex a6c9498..c435ae4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift@@ -337,6 +337,66 @@ public struct IdentityFirstWorkCandidate: Equatable, Sendable {   } } +/// One Site's Work candidates, validated once and bucketed for repeated+/// matching.+///+/// `match` and `identityReuse` take a plain array, and each call re-validates+/// the whole array before linear-scanning it. That is right for a single+/// capture, and wrong for a teaching projection: the projection calls them once+/// per Entry over one unchanging candidate list, so at 5,000 Entries × 1,000+/// Works it re-validates 5M candidates and re-scans 5M more, per preview, on+/// every keystroke (T-1910). Building this once hoists both out of the loop.+///+/// The buckets are sorted by the same `candidateOrder` the scanning arms apply,+/// so an outcome taken from a bucket is the outcome the scan would have+/// produced — including the member order of an `.ambiguous`.+public struct IdentityFirstWorkCandidateIndex: Sendable {+  /// The candidates as supplied, for the counts the planner logs.+  public let candidates: [IdentityFirstWorkCandidate]+  /// Every candidate whose exact matching title is the key.+  fileprivate let byMatchingTitle: [ExactScalarString: [IdentityFirstWorkCandidate]]+  /// Every candidate retaining a *rule*-derived identity equal to the key. The+  /// evidence half of `isIdentityMatch` is still checked per candidate; only+  /// the tuple half is what the key can settle.+  fileprivate let byRetainedRuleIdentity: [ExactScalarString: [IdentityFirstWorkCandidate]]++  /// Validates the candidates exactly as `match` does, then indexes them.+  /// Throws the same `IdentityFirstWorkPlanningError` values, so a caller that+  /// builds the index in place of its first `match` call reports the same+  /// refusal it reported before.+  public init(validating candidates: [IdentityFirstWorkCandidate]) throws {+    try IdentityFirstWorkMatchingPlanner.validate(candidates)+    self.candidates = candidates++    var byTitle: [ExactScalarString: [IdentityFirstWorkCandidate]] = [:]+    var byIdentity: [ExactScalarString: [IdentityFirstWorkCandidate]] = [:]+    for candidate in candidates {+      byTitle[candidate.matchingTitle, default: []].append(candidate)+      if candidate.previousIdentity.state == .rule, let value = candidate.previousIdentity.value {+        byIdentity[value, default: []].append(candidate)+      }+    }+    // Sorting once per bucket is the same total work as sorting the matches of+    // one call, and it is paid once rather than per Entry.+    self.byMatchingTitle = byTitle.mapValues {+      $0.count == 1 ? $0 : $0.sorted(by: IdentityFirstWorkMatchingPlanner.candidateOrder)+    }+    self.byRetainedRuleIdentity = byIdentity.mapValues {+      $0.count == 1 ? $0 : $0.sorted(by: IdentityFirstWorkMatchingPlanner.candidateOrder)+    }+  }++  fileprivate func titleMatches(_ title: ExactScalarString) -> [IdentityFirstWorkCandidate] {+    byMatchingTitle[title] ?? []+  }++  fileprivate func retainedRuleIdentityMatches(+    _ identity: ExactScalarString+  ) -> [IdentityFirstWorkCandidate] {+    byRetainedRuleIdentity[identity] ?? []+  }+}+ public enum ProspectiveWorkKey: Equatable, Hashable, Sendable {   case urlIdentity(ExactScalarString)   case title(ExactScalarString)@@ -372,36 +432,43 @@ public enum IdentityFirstWorkMatchingPlanner {     parsedTitle: ExactScalarString,     candidates: [IdentityFirstWorkCandidate]   ) throws -> IdentityFirstWorkMatchOutcome {-    guard !parsedTitle.isBlank else { throw IdentityFirstWorkPlanningError.blankParsedTitle }-    if let extractedIdentity, extractedIdentity.isBlank {-      throw IdentityFirstWorkPlanningError.blankExtractedIdentity-    }-    try validate(candidates)+    // The input guards run before the candidates are validated, as they always+    // have: a blank title or identity is reported as such even when the+    // candidate array is also invalid.+    try requireNonBlank(extractedIdentity: extractedIdentity, parsedTitle: parsedTitle)+    return try match(+      extractedIdentity: extractedIdentity, parsedTitle: parsedTitle,+      index: IdentityFirstWorkCandidateIndex(validating: candidates))+  }++  /// `match` against candidates already validated and indexed. The answer is+  /// identical; what it saves is the per-call revalidation and full scan, which+  /// a caller matching many Entries against one candidate list pays once+  /// instead of once per Entry (T-1910).+  public static func match(+    extractedIdentity: ExactScalarString?,+    parsedTitle: ExactScalarString,+    index: IdentityFirstWorkCandidateIndex+  ) throws -> IdentityFirstWorkMatchOutcome {+    try requireNonBlank(extractedIdentity: extractedIdentity, parsedTitle: parsedTitle)      guard let extractedIdentity else {-      let titleMatches = candidates-        .filter { $0.matchingTitle == parsedTitle }-        .sorted(by: candidateOrder)       let outcome = existingTitleOutcome(-        matches: titleMatches,+        matches: index.titleMatches(parsedTitle),         createKey: .title(parsedTitle)       )-      logger.debug("Used exact-title fallback across \(candidates.count) Work candidates")+      logger.debug("Used exact-title fallback across \(index.candidates.count) Work candidates")       return outcome     } -    switch identityMatchOutcome(extractedIdentity, among: candidates) {+    switch identityMatchOutcome(extractedIdentity, among: index) {     case .reuse(let workID): return .reuse(workID: workID)     case .ambiguous(let workIDs): return .ambiguous(workIDs: workIDs)     case nil: break     } -    let claimMatches = candidates-      .filter {-        $0.matchingTitle == parsedTitle-          && isClaimEligible($0, identity: extractedIdentity)-      }-      .sorted(by: candidateOrder)+    let claimMatches = index.titleMatches(parsedTitle)+      .filter { isClaimEligible($0, identity: extractedIdentity) }     if claimMatches.count == 1, let match = claimMatches.first {       logger.debug("Claimed one nil-identity Work by exact title and complete evidence")       return .claim(workID: match.id)@@ -424,18 +491,41 @@ public enum IdentityFirstWorkMatchingPlanner {     extractedIdentity: ExactScalarString,     candidates: [IdentityFirstWorkCandidate]   ) throws -> IdentityReuseOutcome? {+    // Input guard before candidate validation, as in `match`.     guard !extractedIdentity.isBlank else { throw IdentityFirstWorkPlanningError.blankExtractedIdentity }-    try validate(candidates)-    return identityMatchOutcome(extractedIdentity, among: candidates)+    return try identityReuse(+      extractedIdentity: extractedIdentity,+      index: IdentityFirstWorkCandidateIndex(validating: candidates))+  }++  /// `identityReuse` against candidates already validated and indexed. See the+  /// index-taking `match`.+  public static func identityReuse(+    extractedIdentity: ExactScalarString,+    index: IdentityFirstWorkCandidateIndex+  ) throws -> IdentityReuseOutcome? {+    guard !extractedIdentity.isBlank else { throw IdentityFirstWorkPlanningError.blankExtractedIdentity }+    return identityMatchOutcome(extractedIdentity, among: index)+  }++  private static func requireNonBlank(+    extractedIdentity: ExactScalarString?, parsedTitle: ExactScalarString+  ) throws {+    guard !parsedTitle.isBlank else { throw IdentityFirstWorkPlanningError.blankParsedTitle }+    if let extractedIdentity, extractedIdentity.isBlank {+      throw IdentityFirstWorkPlanningError.blankExtractedIdentity+    }   }    private static func identityMatchOutcome(     _ extractedIdentity: ExactScalarString,-    among candidates: [IdentityFirstWorkCandidate]+    among index: IdentityFirstWorkCandidateIndex   ) -> IdentityReuseOutcome? {-    let identityMatches = candidates+    // The bucket already holds every candidate whose *tuple* carries this+    // rule-derived identity, in `candidateOrder`; what is left is the evidence+    // half of the same predicate, and filtering preserves the order.+    let identityMatches = index.retainedRuleIdentityMatches(extractedIdentity)       .filter { isIdentityMatch($0, identity: extractedIdentity) }-      .sorted(by: candidateOrder)     if identityMatches.count == 1, let match = identityMatches.first {       logger.debug("Reused one Work by complete URL identity evidence")       return .reuse(workID: match.id)@@ -483,7 +573,7 @@ public enum IdentityFirstWorkMatchingPlanner {     }   } -  private static func validate(_ candidates: [IdentityFirstWorkCandidate]) throws {+  fileprivate static func validate(_ candidates: [IdentityFirstWorkCandidate]) throws {     var candidateIDs: Set<UUID> = []     for candidate in candidates {       guard candidateIDs.insert(candidate.id).inserted else {@@ -587,7 +677,7 @@ public enum IdentityFirstWorkMatchingPlanner {     }   } -  private static func candidateOrder(+  fileprivate static func candidateOrder(     _ lhs: IdentityFirstWorkCandidate,     _ rhs: IdentityFirstWorkCandidate   ) -> Bool {
Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift Modified +62 / -13
diff --git a/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swiftindex e239486..0fbd535 100644--- a/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift@@ -166,36 +166,85 @@ public struct WorkMatchCandidate: Equatable, Sendable, Hashable {     } } +/// One Site's Work candidates bucketed by their exact matching title.+///+/// `WorkMatcher.match` scans every candidate per call. A teaching projection+/// calls it once per Entry over one unchanging candidate list, so at 5,000+/// Entries × 1,000 Works that is 5M scalar-string comparisons per preview, on+/// every keystroke (T-1910). Building this once hoists the scan out of the loop.+///+/// The key is `ExactScalarString`, whose `==` and `hash(into:)` both work on+/// Unicode scalars — the same equality the scan applied. Keying on `String`+/// would silently switch the matcher to canonical equivalence.+public struct WorkMatchIndex: Sendable {+    fileprivate let byMatchingTitle: [ExactScalarString: [WorkMatchCandidate]]++    public init(candidates: [WorkMatchCandidate]) {+        var buckets: [ExactScalarString: [WorkMatchCandidate]] = [:]+        for candidate in candidates {+            buckets[ExactScalarString(WorkMatcher.matchingTitle(of: candidate)), default: []]+                .append(candidate)+        }+        // Pre-sorted into the order an `.ambiguous` reports, so reading a bucket+        // gives the answer the scan gave.+        byMatchingTitle = buckets.mapValues {+            $0.count == 1 ? $0 : $0.sorted { $0.id.uuidString < $1.id.uuidString }+        }+    }++    fileprivate func matches(_ title: String) -> [WorkMatchCandidate] {+        byMatchingTitle[ExactScalarString(title)] ?? []+    }+}+ /// Performs exact scalar Work matching per Requirements 3.4-3.7. public enum WorkMatcher {      /// Match a parsed Work title against candidates using exact scalar equality.     /// Uses `lastParsedTitle` when present and nonblank, otherwise `displayTitle`.+    ///+    /// Deliberately still a scan rather than a call through `WorkMatchIndex`: a+    /// one-shot match pays only a comparison per candidate, and building an+    /// index for it would cost a hash and an allocation per candidate instead.+    /// The index is for callers that match *many* titles against one list.     public static func match(         parsedWorkTitle: String,         candidates: [WorkMatchCandidate]     ) -> WorkMatchOutcome {         var matchingCandidates: [WorkMatchCandidate] = []-        for candidate in candidates {-            let matchTitle: String-            if let lpt = candidate.lastParsedTitle, !M2Unicode.isBlank(lpt) {-                matchTitle = lpt-            } else {-                matchTitle = candidate.displayTitle-            }-            if scalarEqual(matchTitle, parsedWorkTitle) {-                matchingCandidates.append(candidate)-            }+        for candidate in candidates where scalarEqual(matchingTitle(of: candidate), parsedWorkTitle) {+            matchingCandidates.append(candidate)         }-         switch matchingCandidates.count {         case 0: return .create(parsedWorkTitle: parsedWorkTitle)         case 1: return .reuse(workID: matchingCandidates[0].id)         default:             // Sort by UUID string for deterministic order.-            let sorted = matchingCandidates.sorted { $0.id.uuidString < $1.id.uuidString }-            return .ambiguous(candidates: sorted)+            return .ambiguous(candidates: matchingCandidates.sorted { $0.id.uuidString < $1.id.uuidString })+        }+    }++    /// `match` against candidates already indexed. Identical answers; what it+    /// saves is the per-call scan (T-1910).+    public static func match(+        parsedWorkTitle: String,+        index: WorkMatchIndex+    ) -> WorkMatchOutcome {+        let matchingCandidates = index.matches(parsedWorkTitle)+        switch matchingCandidates.count {+        case 0: return .create(parsedWorkTitle: parsedWorkTitle)+        case 1: return .reuse(workID: matchingCandidates[0].id)+        default: return .ambiguous(candidates: matchingCandidates)+        }+    }++    /// The title a candidate matches on: `lastParsedTitle` when present and+    /// nonblank, otherwise `displayTitle`.+    static func matchingTitle(of candidate: WorkMatchCandidate) -> String {+        if let lastParsed = candidate.lastParsedTitle, !M2Unicode.isBlank(lastParsed) {+            return lastParsed         }+        return candidate.displayTitle     }      /// Exact Unicode scalar equality without normalization.
Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift Modified +17 / -10
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swiftindex ed826f6..174243b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift@@ -456,12 +456,19 @@ public enum ComposedTeachingProjectionPlanner {          // Per-Entry assignment (identity-first with title fallback) and the         // eligible create consumers for prospective-Work batching.-        let identityCandidates = identityBearing-            ? try identityFirstCandidates(basis: basis, evidenceByWorkID: evidenceByWorkID)-            : []-        let titleCandidates = basis.works.map {+        //+        // Both candidate sets are **indexed once, here** (T-1910). The matchers+        // also take plain arrays, and doing that would re-validate and re-scan+        // the Site's whole Work list once per Entry: 5,000 Entries × 1,000 Works+        // measured 5.81 s against Req 8.5's 1 s preview budget, for work whose+        // answer cannot change across the loop.+        let identityIndex = try IdentityFirstWorkCandidateIndex(+            validating: identityBearing+                ? identityFirstCandidates(basis: basis, evidenceByWorkID: evidenceByWorkID)+                : [])+        let titleIndex = WorkMatchIndex(candidates: basis.works.map {             WorkMatchCandidate(id: $0.id, lastParsedTitle: $0.lastParsedTitle, displayTitle: $0.displayTitle)-        }+        })          var entryProjections: [ComposedEntryProjection] = []         var prospectiveEntries: [ProspectiveWorkEntry] = []@@ -469,7 +476,7 @@ public enum ComposedTeachingProjectionPlanner {             guard let derivation = derivations[entry.id] else { continue }             let assignment = try assign(                 entry: entry, derivation: derivation, identityBearing: identityBearing,-                identityCandidates: identityCandidates, titleCandidates: titleCandidates)+                identityIndex: identityIndex, titleIndex: titleIndex)             let projection = makeProjection(entry: entry, derivation: derivation, assignment: assignment)             entryProjections.append(projection) @@ -627,7 +634,7 @@ public enum ComposedTeachingProjectionPlanner {      private static func assign(         entry: ComposedEntryBasis, derivation: ComposedDerivation, identityBearing: Bool,-        identityCandidates: [IdentityFirstWorkCandidate], titleCandidates: [WorkMatchCandidate]+        identityIndex: IdentityFirstWorkCandidateIndex, titleIndex: WorkMatchIndex     ) throws -> ComposedAssignmentProjection {         if entry.intentionallyUnattached || entry.workAssignmentProvenance == .manual {             return .protected@@ -638,7 +645,7 @@ public enum ComposedTeachingProjectionPlanner {         // of optional-chapter-sequence), so the guard sits after this.         if identityBearing, let identity = derivation.workIdentity,            let reuse = try IdentityFirstWorkMatchingPlanner.identityReuse(-               extractedIdentity: identity, candidates: identityCandidates) {+               extractedIdentity: identity, index: identityIndex) {             switch reuse {             case .reuse(let workID): return .reuse(workID: workID)             case .ambiguous(let workIDs): return .ambiguous(workIDs: workIDs)@@ -650,7 +657,7 @@ public enum ComposedTeachingProjectionPlanner {         if identityBearing {             let outcome = try IdentityFirstWorkMatchingPlanner.match(                 extractedIdentity: derivation.workIdentity,-                parsedTitle: ExactScalarString(name), candidates: identityCandidates)+                parsedTitle: ExactScalarString(name), index: identityIndex)             switch outcome {             case .reuse(let workID): return .reuse(workID: workID)             case .claim(let workID): return .claim(workID: workID)@@ -658,7 +665,7 @@ public enum ComposedTeachingProjectionPlanner {             case .ambiguous(let workIDs): return .ambiguous(workIDs: workIDs)             }         }-        switch WorkMatcher.match(parsedWorkTitle: name, candidates: titleCandidates) {+        switch WorkMatcher.match(parsedWorkTitle: name, index: titleIndex) {         case .reuse(let workID): return .reuse(workID: workID)         case .create(let title): return .create(key: .title(ExactScalarString(title)))         case .ambiguous(let candidates): return .ambiguous(workIDs: candidates.map(\.id))
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift Modified +136 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swiftindex 279b6d2..fac4aef 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift@@ -15,6 +15,24 @@ import Foundation ///   "collapsed to title-only" preview requests (Req 8.5). /// - `captureBasis` / `captureRequests` → a taught `CaptureBasis` and a batch of ///   new-chapter captures for the capture rule-application budget (Req 6.5, Q9).+///+/// **Work-bearing bases (T-1910).** `composedBasis(entryCount:)` carries no+/// Works, because an untaught Site has none — it is the *initial* teaching+/// sweep. Every Work-matching arm of `ComposedTeachingProjectionPlanner` is+/// therefore unreachable from it, and those arms are the expensive ones. The+/// re-teaching shape, where the Site already holds its 1,000 Works and every+/// Entry re-matches against all of them, is built by:+/// - `titleMatchBasis(entryCount:)` → taught, sequence-only URL rule, 1,000+///   title candidates: the `WorkMatcher` arm.+/// - `identityMatchBasis(entryCount:)` + `identityRequest` → taught, an+///   identity-bearing URL rule, 1,000 candidates each already carrying the+///   rule-derived identity its Entries extract: the+///   `IdentityFirstWorkMatchingPlanner` arms.+///+/// They are deliberately separate members rather than a change to+/// `composedBasis(entryCount:)`: the existing preview arms keep measuring the+/// initial-teaching shape they were banded against, so no other suite over this+/// fixture shifts. struct M4ScaleFixture {     let hostname = LibraryRepository.m4FixtureHostname     let entryCount = LibraryRepository.m4FixtureEntryCount@@ -54,6 +72,124 @@ struct M4ScaleFixture {             workAssignmentProvenance: .none, intentionallyUnattached: false)     } +    // MARK: - Work-bearing (re-teaching) preview bases — T-1910++    /// The URL rule id the taught bases cite. Works carrying a rule-derived+    /// identity must reference a rule row, and `deriveURLIdentity` takes the+    /// basis's own current rule id, so both sides have to name the same UUID.+    static let urlRuleID = LibraryRepository.m4FixtureUUID(namespace: 2, index: 0)++    /// The per-Work slug the identity-bearing URL rule extracts.+    static func workSlug(workIndex: Int) -> String { "story-\(workIndex)" }++    /// A raw URL carrying the Work slug *and* the chapter sequence, so an+    /// identity-bearing rule has something to extract. `uniqueBy` keeps every+    /// Entry's immutable raw URL distinct without affecting either field.+    static func identityRawURL(+        hostname: String, workIndex: Int, chapter: Int, uniqueBy: Int+    ) -> String {+        "https://\(hostname)/read?story=\(workSlug(workIndex: workIndex))&chapter=\(chapter)&e=\(uniqueBy)"+    }++    /// `.workAndSequence`, the simplest identity-bearing form (`suppliesIdentity`+    /// is true for every M3 form and false only for the sequence-only rule the+    /// rest of this fixture teaches).+    static func identityURLDefinition() -> URLRuleDefinition {+        .workAndSequence(+            work: URLFieldSelector(locator: .query(name: ExactScalarString("story"))),+            sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter"))))+    }++    /// The composed request whose URL rule supplies a Work identity, so the+    /// projection takes the identity-first assignment arms.+    var identityRequest: ComposedTeachingRequest {+        ComposedTeachingRequest(+            titleDefinition: LibraryRepository.m4FixtureTitleDefinition(),+            trimPrefix: LibraryRepository.m4FixtureTrimPrefix,+            trimSuffix: LibraryRepository.m4FixtureTrimSuffix,+            urlDefinition: Self.identityURLDefinition())+    }++    /// A taught basis over the sequence-only URL rule: `entryCount` Entries and+    /// the Site's full 1,000 Works, none of them carrying a URL identity. The+    /// projection's title-matching arm (`WorkMatcher.match`) then runs once per+    /// Entry against all 1,000 candidates.+    ///+    /// The Work list is always the whole Site, whatever `entryCount` is: a+    /// single-Entry edit acknowledgement re-matches against every Work the Site+    /// holds, which is exactly the keystroke-path cost this measures.+    func titleMatchBasis(entryCount: Int) -> ComposedTeachingBasis {+        ComposedTeachingBasis(+            siteMode: .taught, hostname: hostname,+            entries: (0..<entryCount).map { taughtEntryBasis(entryIndex: $0, identityBearing: false) },+            works: (0..<workCount).map { workBasis(workIndex: $0, identityBearing: false) },+            currentTitleRule: titleRuleBasis,+            currentURLRule: urlRuleBasis(LibraryRepository.m4FixtureURLDefinition()))+    }++    /// A taught basis over an identity-bearing URL rule: `entryCount` Entries+    /// whose URLs carry their Work's slug, and the Site's full 1,000 Works, each+    /// already retaining the identity its own Entries extract. Every Entry then+    /// reaches `IdentityFirstWorkMatchingPlanner` and reuses its Work by+    /// identity — the steady state of a taught, identity-bearing Site.+    func identityMatchBasis(entryCount: Int) -> ComposedTeachingBasis {+        ComposedTeachingBasis(+            siteMode: .taught, hostname: hostname,+            entries: (0..<entryCount).map { taughtEntryBasis(entryIndex: $0, identityBearing: true) },+            works: (0..<workCount).map { workBasis(workIndex: $0, identityBearing: true) },+            currentTitleRule: titleRuleBasis,+            currentURLRule: urlRuleBasis(Self.identityURLDefinition()))+    }++    private var titleRuleBasis: ComposedTitleRuleBasis {+        ComposedTitleRuleBasis(+            id: LibraryRepository.m4FixtureUUID(namespace: 1, index: 0), version: 1,+            definition: LibraryRepository.m4FixtureTitleDefinition(),+            trimPrefix: LibraryRepository.m4FixtureTrimPrefix,+            trimSuffix: LibraryRepository.m4FixtureTrimSuffix)+    }++    private func urlRuleBasis(_ definition: URLRuleDefinition) -> ComposedURLRuleBasis {+        ComposedURLRuleBasis(+            id: Self.urlRuleID, version: 1, origin: .readerTaught, definition: definition)+    }++    /// An Entry already assigned to its Work with pattern provenance — the shape+    /// a taught Site's rows carry. Pattern provenance is deliberate: `.manual`+    /// would make `assign` return `.protected` and skip every matching arm this+    /// basis exists to reach.+    private func taughtEntryBasis(entryIndex: Int, identityBearing: Bool) -> ComposedEntryBasis {+        let workIndex = entryIndex / LibraryRepository.m4FixtureEntriesPerWork+        let chapter = entryIndex % LibraryRepository.m4FixtureEntriesPerWork + 1+        let rawURL = identityBearing+            ? Self.identityRawURL(+                hostname: hostname, workIndex: workIndex, chapter: chapter, uniqueBy: entryIndex)+            : LibraryRepository.m4FixtureRawURL(+                hostname: hostname, chapter: chapter, uniqueBy: entryIndex)+        return ComposedEntryBasis(+            id: LibraryRepository.m4FixtureUUID(namespace: 11, index: entryIndex),+            captureTitle: LibraryRepository.m4FixtureCaptureTitle(workIndex: workIndex, chapter: chapter),+            rawURLString: rawURL,+            hostname: hostname,+            firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(entryIndex)),+            chapterTitle: nil, chapterTitleProvenance: .none,+            workID: LibraryRepository.m4FixtureUUID(namespace: 10, index: workIndex),+            workAssignmentProvenance: .pattern, intentionallyUnattached: false)+    }++    private func workBasis(workIndex: Int, identityBearing: Bool) -> ComposedWorkBasis {+        let name = LibraryRepository.m4FixtureWorkTitle(workIndex: workIndex)+        let identity: WorkIdentitySnapshot = identityBearing+            ? WorkIdentitySnapshot(+                value: ExactScalarString(Self.workSlug(workIndex: workIndex)),+                state: .rule, ruleReference: URLRuleReference(id: Self.urlRuleID))+            : .none+        return ComposedWorkBasis(+            id: LibraryRepository.m4FixtureUUID(namespace: 10, index: workIndex),+            displayTitle: name, lastParsedTitle: name,+            titleProvenance: .parsed, identity: identity)+    }+     // MARK: - Capture rule-application basis      /// The number of Works the taught capture basis carries (5,000 / 5).
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift Modified +65 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex a7af7db..d0a81be 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -55,6 +55,71 @@ struct M4ScaleFixtureTests {         #expect(outcome.entries.first?.projectedKeyVersion == 3)     } +    // MARK: - Work-bearing (re-teaching) preview bases — T-1910++    /// The bug this pins: the budgeted basis has to *reach* the Work-matching+    /// arms. `composedBasis` carries no Works at all, so nothing in+    /// `WorkMatcher` or `IdentityFirstWorkMatchingPlanner` runs over it — the+    /// arms the 1-second preview budget exists to bound were unmeasured.+    @Test("The initial-teaching basis reaches no Work-matching arm at all")+    func initialTeachingBasisHasNoWorkCandidates() throws {+        let fixture = M4ScaleFixture()+        #expect(fixture.composedBasis(entryCount: fixture.entryCount).works.isEmpty)+    }++    @Test("The title-matching basis reuses all 1,000 existing Works by exact title")+    func titleMatchBasisReusesEveryWork() throws {+        let fixture = M4ScaleFixture()+        let basis = fixture.titleMatchBasis(entryCount: fixture.entryCount)+        #expect(basis.works.count == fixture.workCount)+        let outcome = try ComposedTeachingProjectionPlanner.project(+            basis: basis, request: fixture.composedRequest)++        #expect(outcome.entries.count == fixture.entryCount)+        // Every Entry matched an existing Work by title, so nothing is+        // prospective and every projected Work ID is one the basis supplied.+        #expect(outcome.prospectiveWorks.isEmpty)+        let workIDs = Set(basis.works.map(\.id))+        #expect(outcome.entries.allSatisfy { $0.projectedWorkID.map(workIDs.contains) == true })+        #expect(outcome.entries.allSatisfy { $0.projectedKeyVersion == 3 })+    }++    @Test("The identity-matching basis reuses all 1,000 existing Works by URL identity")+    func identityMatchBasisReusesEveryWork() throws {+        let fixture = M4ScaleFixture()+        let basis = fixture.identityMatchBasis(entryCount: fixture.entryCount)+        let request = fixture.identityRequest+        #expect(request.urlDefinition?.suppliesIdentity == true)++        let outcome = try ComposedTeachingProjectionPlanner.project(basis: basis, request: request)+        #expect(outcome.entries.count == fixture.entryCount)+        #expect(outcome.prospectiveWorks.isEmpty)+        #expect(outcome.issues.isEmpty)+        // Identity-bearing rules derive per-Work evidence, so the projection+        // reports one Work row per candidate — the shape the identity-first+        // matching arms consume.+        #expect(outcome.works.count == fixture.workCount)+        let workIDs = Set(basis.works.map(\.id))+        #expect(outcome.entries.allSatisfy { $0.projectedWorkID.map(workIDs.contains) == true })+    }++    @Test("A single-Entry Work-bearing preview still matches against all 1,000 Works")+    func editAckBasesCarryEveryWork() throws {+        let fixture = M4ScaleFixture()+        for basis in [+            fixture.titleMatchBasis(entryCount: 1), fixture.identityMatchBasis(entryCount: 1)+        ] {+            #expect(basis.entries.count == 1)+            #expect(basis.works.count == fixture.workCount)+        }+        let title = try ComposedTeachingProjectionPlanner.project(+            basis: fixture.titleMatchBasis(entryCount: 1), request: fixture.composedRequest)+        #expect(title.entries.first?.projectedWorkID != nil)+        let identity = try ComposedTeachingProjectionPlanner.project(+            basis: fixture.identityMatchBasis(entryCount: 1), request: fixture.identityRequest)+        #expect(identity.entries.first?.projectedWorkID != nil)+    }+     // MARK: - Capture rule application      @Test("Capture rule application derives a v3 key and reuses the title-matched Work")
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift Modified +57 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swiftindex 9134f65..e6e62be 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift@@ -98,6 +98,63 @@ struct M4ScalePerformanceTests {         expectWithinBudget("complete-preview-collapsed", measured, completePreviewBudget)     } +    // MARK: - Preview budgets over a Site that already holds its Works (T-1910)++    /// The four arms above all project a basis with **no Works** — the initial+    /// teaching sweep of an untaught Site, which by construction has none. That+    /// left every Work-matching arm of `ComposedTeachingProjectionPlanner`+    /// outside the budget the arms exist to guard, and those are the expensive+    /// ones: matching runs once per Entry against every Work the Site holds.+    ///+    /// The four arms below project the re-teaching shape instead — the same+    /// 5,000 Entries over the Site's own 1,000 Works — so the same Req 8.5+    /// budgets bound the title-matching arm (`WorkMatcher`) and the+    /// identity-first arms (`IdentityFirstWorkMatchingPlanner`).++    @Test("Edit acknowledgement ≤ 100 ms — title matching against 1,000 Works")+    func editAckTitleMatching() throws {+        let fixture = M4ScaleFixture()+        let basis = fixture.titleMatchBasis(entryCount: 1)+        let request = fixture.composedRequest+        let measured = try measureDistribution(iterations: iterations) {+            _ = try ComposedTeachingProjectionPlanner.project(basis: basis, request: request)+        }+        expectWithinBudget("edit-ack-title-matching", measured, editAckBudget)+    }++    @Test("Edit acknowledgement ≤ 100 ms — identity-first matching against 1,000 Works")+    func editAckIdentityMatching() throws {+        let fixture = M4ScaleFixture()+        let basis = fixture.identityMatchBasis(entryCount: 1)+        let request = fixture.identityRequest+        let measured = try measureDistribution(iterations: iterations) {+            _ = try ComposedTeachingProjectionPlanner.project(basis: basis, request: request)+        }+        expectWithinBudget("edit-ack-identity-matching", measured, editAckBudget)+    }++    @Test("Complete 5,000-Entry preview ≤ 1 s — title matching against 1,000 Works")+    func completePreviewTitleMatching() throws {+        let fixture = M4ScaleFixture()+        let basis = fixture.titleMatchBasis(entryCount: fixture.entryCount)+        let request = fixture.composedRequest+        let measured = try measureDistribution(iterations: iterations) {+            _ = try ComposedTeachingProjectionPlanner.project(basis: basis, request: request)+        }+        expectWithinBudget("complete-preview-title-matching", measured, completePreviewBudget)+    }++    @Test("Complete 5,000-Entry preview ≤ 1 s — identity-first matching against 1,000 Works")+    func completePreviewIdentityMatching() throws {+        let fixture = M4ScaleFixture()+        let basis = fixture.identityMatchBasis(entryCount: fixture.entryCount)+        let request = fixture.identityRequest+        let measured = try measureDistribution(iterations: iterations) {+            _ = try ComposedTeachingProjectionPlanner.project(basis: basis, request: request)+        }+        expectWithinBudget("complete-preview-identity-matching", measured, completePreviewBudget)+    }+     // MARK: - Capture rule-application budget (Req 6.5, Q9)      @Test("Capture rule-application ≤ 100 ms against the composed fixture")
Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift Modified +38 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swiftindex 2ecaf46..0182dbf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLIdentityPlannerTests.swift@@ -364,6 +364,44 @@ struct IdentityFirstWorkMatchingTests {     #expect(intents.contains { $0.key == .title(ExactScalarString("Latest winner")) })   } +  @Test("Blank inputs are reported before the candidate array is validated")+  func blankInputsBeforeCandidateValidation() throws {+    // The array-taking forms build an index, which validates the candidates.+    // The input guards still run first, so a blank title or identity is what+    // a caller hears about even when its candidates are also invalid (T-1910).+    let duplicated = candidate(+      1,+      title: "Series 42",+      identity: .none,+      evidence: .noEntries(previousIdentity: .none)+    )+    let invalidCandidates = [duplicated, duplicated]+    #expect(throws: IdentityFirstWorkPlanningError.duplicateCandidateID(duplicated.id)) {+      try IdentityFirstWorkCandidateIndex(validating: invalidCandidates)+    }++    #expect(throws: IdentityFirstWorkPlanningError.blankParsedTitle) {+      try IdentityFirstWorkMatchingPlanner.match(+        extractedIdentity: ExactScalarString("series-42"),+        parsedTitle: ExactScalarString("   "),+        candidates: invalidCandidates+      )+    }+    #expect(throws: IdentityFirstWorkPlanningError.blankExtractedIdentity) {+      try IdentityFirstWorkMatchingPlanner.match(+        extractedIdentity: ExactScalarString(""),+        parsedTitle: ExactScalarString("Series 42"),+        candidates: invalidCandidates+      )+    }+    #expect(throws: IdentityFirstWorkPlanningError.blankExtractedIdentity) {+      try IdentityFirstWorkMatchingPlanner.identityReuse(+        extractedIdentity: ExactScalarString(" "),+        candidates: invalidCandidates+      )+    }+  }+   private func candidate(     _ suffix: Int,     title: String,
Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift Modified +28 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swiftindex 4ab92ea..ea7bf1b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift@@ -556,6 +556,34 @@ struct WorkMatchingTests {         let outcome = WorkMatcher.match(parsedWorkTitle: "Cafe\u{0301}", candidates: candidates)         #expect(outcome == .create(parsedWorkTitle: "Cafe\u{0301}"))     }++    @Test("Indexed matching answers as the scan does, ambiguous member order included")+    func indexMatchesScan() {+        // Two Works share a title, a third does not; supplied in the reverse+        // of the UUID order an `.ambiguous` reports (T-1910).+        let low = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!+        let high = UUID(uuidString: "00000000-0000-0000-0000-000000000002")!+        let other = UUID(uuidString: "00000000-0000-0000-0000-000000000003")!+        let candidates = [+            WorkMatchCandidate(id: high, lastParsedTitle: "Fiction Name", displayTitle: "Fiction Name (B)"),+            WorkMatchCandidate(id: other, lastParsedTitle: nil, displayTitle: "Other Name"),+            WorkMatchCandidate(id: low, lastParsedTitle: "   ", displayTitle: "Fiction Name"),+        ]+        let index = WorkMatchIndex(candidates: candidates)++        for title in ["Fiction Name", "Other Name", "Fiction Name (B)", "Cafe\u{0301}"] {+            #expect(+                WorkMatcher.match(parsedWorkTitle: title, index: index)+                    == WorkMatcher.match(parsedWorkTitle: title, candidates: candidates))+        }+        #expect(+            WorkMatcher.match(parsedWorkTitle: "Fiction Name", index: index)+                == .ambiguous(candidates: [candidates[2], candidates[0]]))+        #expect(WorkMatcher.match(parsedWorkTitle: "Other Name", index: index) == .reuse(workID: other))+        #expect(+            WorkMatcher.match(parsedWorkTitle: "Fiction Name (B)", index: index)+                == .create(parsedWorkTitle: "Fiction Name (B)"))+    } }  // MARK: - Actionability tests
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift Modified +27 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swiftindex fd22cf9..564dba4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingProjectionTests.swift@@ -247,6 +247,33 @@ struct ComposedTeachingProjectionTests {         #expect(p2.assignment == .protected)     } +    @Test("An invalid Work list is refused even when every Entry is protected")+    func invalidWorksRefusedWithProtectedEntries() throws {+        // Candidate validation is eager — once, before the per-Entry loop — so+        // a Work no matcher would ever be asked about is still refused. Before+        // T-1910 this library projected, because validation ran lazily inside+        // the first match call and protected Entries never make one.+        let blankWork = UUID(), e1 = UUID()+        let basis = ComposedTeachingBasis(+            siteMode: .untaught, hostname: host,+            entries: [+                entry(e1, title: "Chapter 7 - Real Work", url: "https://ex.com/read?id=42&chapter=7",+                      at: 1, workID: blankWork, assignmentProv: .manual),+            ],+            works: [ComposedWorkBasis(+                id: blankWork, displayTitle: "   ", lastParsedTitle: nil,+                titleProvenance: .manual, identity: .none)],+            currentTitleRule: nil, currentURLRule: nil)+        let request = ComposedTeachingRequest(+            titleDefinition: try wcSegment(), urlDefinition: identitySequenceURL())++        #expect(throws: IdentityFirstWorkPlanningError.invalidCandidate(+            id: blankWork, reason: "matching title is blank")+        ) {+            try ComposedTeachingProjectionPlanner.project(basis: basis, request: request)+        }+    }+     // MARK: - Failure template: S-Site title parse failure (Q24)      @Test("Sequence-only Site with a title parse failure: conservative key, sequence evidence, actionable name")
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 32fee26..921d519 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -58,7 +58,7 @@ invocations where a target exists. - `make test-quick` — unit-test bundle only (simulator), preceded by `build-mac`: a macOS compile failure fails it (Req 9.1). The Mac build is never installed or launched. `SKIP_MAC=1` drops that dependency loudly and owes a clean `make build-mac` before the push. - `make test` / `make test-ui` — full suites (simulator, iPhone); they skip the iPad-only suites by name - `make test-ui-ipad` — the wide-layout and wide-layout-accessibility suites on `IPAD_SIMULATOR` (simulator, safe)-- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, and 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, plus a ~190 s release build): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **eight** since `drop-superseded-columns` (four before `multi-site-works`, nine after it). Four are long-standing: Req 10.1's settling pass and Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → **0.169–0.176 s at V10**, the one on a path the reader waits on; the three new `Work` columns and the wider `orderComponents` cost them 3–6%, still well inside a 250 ms ceiling). The eighth is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10). Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-and-reading-status/verification-run.md` §4 for the current numbers, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band.+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, and 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and 1,380 s over 32 tests on 2026-09-05 after T-1910 added four Work-bearing preview arms, plus a ~190 s release build): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **eight** since `drop-superseded-columns` (four before `multi-site-works`, nine after it). Four are long-standing: Req 10.1's settling pass and Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → **0.169–0.176 s at V10**, the one on a path the reader waits on; the three new `Work` columns and the wider `orderComponents` cost them 3–6%, still well inside a 250 ms ceiling). The eighth is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10). Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-and-reading-status/verification-run.md` §4 for the current numbers, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band. - `make test-performance-chunks` — host-only calibration sweep of the shared bulk chunk constant (import commits and the reconciler re-pin). No device, safe to run, but gated on `ASTERISM_RUN_CHUNK_SWEEP=1` and **~20 minutes per run**, so it is deliberately *not* part of `make test-performance-m4`. It asserts nothing — a calibration is reported, not budgeted. Re-run it when the bulk write paths change (Q53 and the task 25 section of `specs/cloudkit-mirroring/implementation.md`). - `make test-performance-m4-recent` — **physical device, see above** 
docs/agent-notes/testing.md Modified +12 / -1
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex be5cd09..a6897af 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -220,7 +220,8 @@ Consequences: added the membership suite (1,494 s measured 2026-08-26), and **~21 minutes** since `drop-superseded-columns` (1,093 s of test time measured 2026-08-28, and 1,120 s over 28 tests measured 2026-08-30, after `character-ranking` added its-own).+own, and 1,380 s over **32** tests on 2026-09-05, after T-1910 added four+Work-bearing preview arms). It reported **four or five known issues** — Req 10.1's settling pass (`duplicate-reconciliation` Decision 27), Req 5.5's three diagnosis re-derivations (`library-integrity-tolerance` Decision 11), and,@@ -265,6 +266,16 @@ Zero would mean the filter or the opt-in gate stopped the suites from running at all, which is the failure mode the Makefile's no-xcbeautify comment exists for. +**A scale fixture can be silently missing the shape a budget guards.** T-1910:+`M4ScaleFixture.composedBasis` built its 5,000-Entry preview basis with+`works: []`, so every Work-matching arm of `ComposedTeachingProjectionPlanner`+sat outside the Req 8.5 budget that exists to bound it. Once the Work-bearing+re-teaching shape was given a basis, the identity-bearing preview measured+**5.82 s against a 1 s budget**. The lesson generalises: a green budget over a+fixture nobody re-read after the code gained a second path proves nothing about+that path. `M4ScaleFixtureTests` now pins that the budgeted bases actually reach+the arms — assert reachability, not only timings.+ **A budget that leaves its band is not always a code regression — check the fixture first.** Task 22 of `multi-site-works` found `reconcile-worst-case-consolidation` failing at 5,000 of 6,000 records re-pinned
specs/bugfixes/m4-fixture-work-matching/report.md Added +245 / -0
diff --git a/specs/bugfixes/m4-fixture-work-matching/report.md b/specs/bugfixes/m4-fixture-work-matching/report.mdnew file mode 100644index 0000000..740f842--- /dev/null+++ b/specs/bugfixes/m4-fixture-work-matching/report.md@@ -0,0 +1,245 @@+# Bugfix Report: M4 performance fixture doesn't exercise Work matching++**Date:** 2026-09-05+**Status:** Fixed+**Ticket:** T-1910++## Description of the Issue++`M4ScaleFixture.composedBasis(entryCount:)` builds its 5,000-Entry composed+teaching basis with `works: []` and a sequence-only URL rule. Every+Work-matching path in `ComposedTeachingProjectionPlanner` was therefore+unreachable from the four preview arms of `M4ScalePerformanceTests` — the arms+that exist to guard Req 8.5 (edit acknowledgement p95 ≤ 100 ms, complete preview+p95 ≤ 1 s).++The budget was green because it measured a shape that skips the expensive work,+not because the expensive work fits inside it.++**Reproduction steps:**++1. Read `M4ScaleFixture.composedBasis(entryCount:)`: `works: []`.+2. Read `ComposedTeachingProjectionPlanner.project`: with no Works both the+   identity-first candidate list and the title candidate list are empty, so+   `assign` returns `.create` for every Entry without ever scanning a candidate.+3. Run `make test-performance-m4`: `complete-preview-expanded` reports 0.083 s+   against a 1 s budget, and no measurement anywhere covers the case where the+   Site already holds its 1,000 Works.++**Impact:** a silent gap in a stated requirement's guard. The unmeasured paths+are the ones a reader actually pays for — re-teaching a taught Site runs Work+matching once per Entry over every Work the Site holds, on every+keystroke-triggered preview. Measured for the first time here, the+identity-bearing shape took **5.82 s median against a 1 s budget**.++## Investigation Summary++- **Symptoms examined:** the fixture's basis shape versus the code paths the+  budget claims to bound.+- **Code inspected:**+  - `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift`+  - `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift`+  - `Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift`+    (`project`, `identityFirstCandidates`, `assign`)+  - `Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift`+    (`IdentityFirstWorkMatchingPlanner.match` / `identityReuse` / `validate`)+  - `Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift`+    (`WorkMatcher.match`)+- **Hypotheses tested:**+  - *Could the existing arms reach the matching code at all?* No — `basis.works`+    is empty, so both candidate arrays are empty and every arm short-circuits.+  - *Does the capture budget already cover it?* No — capture matches **one**+    Entry against the Work list. That is linear and comfortably inside its+    100 ms budget (0.0002 s measured); the projection's cost is the *per-Entry*+    repetition, which capture never pays.+  - *Is the cost the derivation or the matching?* The matching. The same 5,000+    Entries with no Works project in 0.083 s; with 1,000 Works and a+    sequence-only rule, 0.506 s; with 1,000 Works and an identity-bearing rule,+    5.82 s.++## Discovered Root Cause++Two defects, the first hiding the second.++**1. The fixture (the reported bug).** `composedBasis` models the *initial*+teaching sweep of an untaught Site, which by construction owns no Works. That is+a legitimate shape, but it was the only shape the preview budget measured, and+it is the cheap one. The re-teaching shape — a taught Site with its 1,000 Works,+every Entry re-matched against all of them — had no fixture and therefore no+budget.++**2. The code the fixture was hiding.** Once measured, the identity-bearing+preview costs 5.82 s against a 1 s budget, because+`IdentityFirstWorkMatchingPlanner` is called once per Entry and each call:++- re-validates the **entire** candidate array (`validate` builds a fresh+  `Set<UUID>` over all candidates and, per candidate, `validateEvidence`+  allocates another `Set<UUID>`), and+- linear-scans and sorts every candidate for the identity arm, and again for the+  title arm.++At 5,000 Entries × 1,000 Works that is ~5M candidate validations, ~5M `Set`+allocations and ~10M exact-scalar comparisons per preview — work whose result+cannot change across the loop, because the candidate list does not change.++`WorkMatcher.match` has the same shape on the title arm: a full scan per Entry+over an unchanging candidate list. That one is cheaper per candidate (a+comparison, no allocation), which is why it costs 0.42 s rather than 5.7 s.++**Defect type:** missing test coverage (1) concealing an algorithmic defect —+loop-invariant work performed once per iteration (2).++**Why it occurred:** the fixture was written for the initial-teaching journey+the feature was being built for, and the budget was attached to it. Identity-first+matching landed on the same planner a milestone later and nothing re-read the+fixture.++## Resolution for the Issue++**Changes made:**++- `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift` — added+  `titleMatchBasis(entryCount:)` and `identityMatchBasis(entryCount:)` plus+  `identityRequest` and the identity-bearing URL shape they need+  (`.workAndSequence` over a `story` slug and the existing `chapter` sequence).+  Both carry the Site's full 1,000 Works whatever the Entry count, because a+  single-Entry edit acknowledgement also re-matches against every Work. Entries+  carry their Work id at `.pattern` provenance — `.manual` would make `assign`+  return `.protected` and skip the arms outright.+  `composedBasis(entryCount:)` is **unchanged**, so every existing measurement+  over this fixture keeps the shape it was banded against.+- `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift`+  — four new arms holding the same Req 8.5 budgets over the Work-bearing bases.+- `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift` —+  correctness tests (they run in the default `make test-core`) pinning that the+  new bases really do reach the matching arms and reuse every existing Work, and+  that the old basis reaches none of them.+- `Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift` — added+  `IdentityFirstWorkCandidateIndex`: candidates validated once and bucketed by+  exact matching title and by retained rule identity. `match` and+  `identityReuse` gained index-taking overloads; the array-taking entry points+  are thin wrappers that build a single-use index, so every existing caller+  keeps its behaviour and its error cases.+- `Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift` — added+  `WorkMatchIndex` and `WorkMatcher.match(parsedWorkTitle:index:)`. The+  array-taking `match` deliberately keeps its scan rather than routing through+  the index: a one-shot match pays only a comparison per candidate, and building+  an index for it would cost a hash and an allocation per candidate instead.+- `Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift`+  — `project` builds both indices once, before the per-Entry loop, and `assign`+  takes them.++**Approach rationale:** the buckets are pre-sorted with the same comparator the+scanning arms applied (`candidateOrder` for identity-first, UUID string order for+`WorkMatcher`), so an outcome read out of a bucket is identical to the one the+scan produced — including the member order of an `.ambiguous`.+`ExactScalarString` hashes and compares by Unicode scalar, so a dictionary keyed+on it is exactly the equality the matchers already used; keying on `String` would+have silently switched them to canonical equivalence.++**One deliberate behaviour change.** Candidate validation is now eager: `project`+validates the whole candidate list once at the top rather than lazily inside the+first `match` call that happens to run. A library whose Work list is invalid *and*+whose every Entry is protected therefore now throws where it previously projected.+That is the stricter and more deterministic answer — the refusal no longer depends+on which Entries happen to reach the arm — and every shape it refuses is one the+library validator already rejects.++**Alternatives considered:**++- **Fixture only, no optimisation** (the ticket's stated minimum). Rejected: it+  would leave `make test-performance-m4` red, and that target's contract is that+  it exits 0 with accepted breaches recorded as known issues. A 5.8× breach on a+  path a reader hits per keystroke is not an accepted breach.+- **Change `composedBasis` to carry Works.** Rejected: several suites measure+  over it and their recorded bands are in CLAUDE.md and the verification-run+  files. Adding Works there would have shifted every one of them at once, which+  is exactly what the ticket warned against.+- **Memoise the validation inside the planner** (a static cache keyed on the+  candidate array). Rejected: shared mutable state in a pure planner, and the key+  would have to hash the whole array anyway.++## Regression Test++**Test files:**++- `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift` —+  `initialTeachingBasisHasNoWorkCandidates`, `titleMatchBasisReusesEveryWork`,+  `identityMatchBasisReusesEveryWork`, `editAckBasesCarryEveryWork`.+- `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift`+  — `editAckTitleMatching`, `editAckIdentityMatching`,+  `completePreviewTitleMatching`, `completePreviewIdentityMatching`.++**What they verify:** that the budgeted basis reaches the Work-matching arms at+all (the correctness tests, which fail loudly if a future change empties the+candidate lists again), and that those arms fit inside the Req 8.5 budgets (the+timed arms). `completePreviewIdentityMatching` is the failing test the fix is+anchored to: 5.82 s before, well inside 1 s after.++**Run commands:**++- `make test-core` — the correctness tests.+- `make test-performance-m4` — the timed arms (host only, ~21 minutes, safe).++## Affected Files++| File | Change |+|------|--------|+| `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixture.swift` | Work-bearing re-teaching bases |+| `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift` | Correctness pins for the new bases |+| `Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift` | Four new Req 8.5 budget arms |+| `Packages/AsterismCore/Sources/AsterismCore/URLIdentityPlanner.swift` | `IdentityFirstWorkCandidateIndex`; index-taking `match` / `identityReuse` |+| `Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift` | `WorkMatchIndex`; index-taking `WorkMatcher.match` |+| `Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift` | Build both indices once per projection |++## Verification++**Automated:**++- [x] The regression test fails before the fix and passes after it:+      `complete-preview-identity-matching` 5.82 s → 0.19 s against a 1 s budget.+- [x] `make test-core` exits 0 (a later re-run tripped on the two live Apple+      Intelligence calls, which pass on their own and are not in AsterismCore —+      see `measurements.md` §5).+- [x] `make test-performance-m4` reports the same **8 known issues** CLAUDE.md+      documents, over 32 tests rather than 28. One run failed a regression+      ceiling on `capture-projection-duplicateSiteRows`, an arm this change does+      not touch; a control run with the change stashed and a third run on the+      branch both pass, and `measurements.md` §4 sets out the evidence that it+      was host contention.+- [x] No new compiler warnings from the changed files (`swift build` and+      `swift build --build-tests`, unwrapped).+- [x] No linter: the repo has none. A clean `make test-core` is the bar.++**Owed:** one `make test-performance-m4` on a quiet machine, end to end.++The measured numbers and the verbatim run summaries are in `measurements.md`+beside this file.++## Prevention++- A performance fixture is part of the assertion, not scaffolding for it. When a+  budget guards a code path, something has to pin that the fixture *reaches* it —+  which is why the new correctness tests assert candidate counts and reuse+  outcomes rather than only timings.+- When a planner gains a second matching arm, re-read the fixtures that budget+  the planner.+- A matcher that validates its whole input on every call is fine for a capture+  and quadratic for a sweep. Where a caller matches many things against one list,+  the list belongs in an index built outside the loop.++## Not Done++- `TitleProjectionPlanner` (`TitleProjectionPlanner.swift`, the M2 title-only+  projection) calls `WorkMatcher.match` once per Entry over a loop-invariant+  candidate list and would benefit from the same `WorkMatchIndex` hoist. It is+  out of scope here: nothing in this ticket measures it, and there is no failing+  test to anchor the change. Worth its own ticket.++## Related++- Ticket T-1910, raised as a skipped finding in the pre-push review of+  `feature/unified-teaching-composition`.+- `CLAUDE.md` § "Build and test tooling" — the `make test-performance-m4`+  known-issue bands.+- `docs/agent-notes/testing.md` § "`make test-performance-m4` exits 0".
specs/bugfixes/m4-fixture-work-matching/measurements.md Added +148 / -0
diff --git a/specs/bugfixes/m4-fixture-work-matching/measurements.md b/specs/bugfixes/m4-fixture-work-matching/measurements.mdnew file mode 100644index 0000000..ab52e5e--- /dev/null+++ b/specs/bugfixes/m4-fixture-work-matching/measurements.md@@ -0,0 +1,148 @@+# Measurements: T-1910++All numbers are `swift test -c release -Xswiftc -DASTERISM_PERFORMANCE_TESTING`+on the host (`make test-performance-m4`'s own configuration), 2026-09-05.+`median` is the regression statistic; `p95` is reported but asserted only on a+run declared `CONTROLLED=1` (Decision 10 of `library-integrity-tolerance`).++**Read the host caveat (§4).** Three sibling agent worktrees were running their+own `swift test` loops throughout, and the host load average sat between 13 and+29. The arms this change touches moved far enough that noise cannot explain+them. One run of the full target failed a regression ceiling on an arm this+change does not touch; §4 shows, by a control run with the change stashed and a+third run on the branch, that it was the host.++## 1. The bug, measured++The four preview arms that existed all project a basis with **no Works**. The+four added here project the same 5,000 Entries over the Site's own 1,000 Works.++| arm | before | after | budget |+|---|---|---|---|+| `edit-ack-title-matching` | 0.000290 s | 0.000702 s | 100 ms |+| `edit-ack-identity-matching` | 0.002756 s | 0.004372 s | 100 ms |+| `complete-preview-title-matching` | 0.505781 s | **0.083442 s** | 1 s |+| `complete-preview-identity-matching` | **5.822792 s** | **0.189502 s** | 1 s |++`complete-preview-identity-matching` is the failing test the fix is anchored to.+Before the fix it exceeded its 1-second budget by **5.8×**; it was reproduced+twice at 5.813 s and 5.823 s, so it is not a noise artefact. After the fix it is+**31× faster** and sits at 19% of the budget.++`complete-preview-title-matching` fell 6.1× and now costs the same as the+Work-free arm — the `WorkMatcher` scan is gone from the per-Entry loop entirely.++The two edit-acknowledgement arms got *slower* in absolute terms and this is+expected: with one Entry, building an index over 1,000 candidates costs more+than one scan over them. They are 140× and 23× inside their 100 ms budget, and+the shape they measure — one keystroke — is not the shape the bug was about.+Trading a 0.4 ms regression on a single Entry for 5.6 s on the whole preview is+the right trade, and the budgets say so.++## 2. The arms this change does not touch, before and after++Unchanged within run-to-run noise:++| arm | before | after |+|---|---|---|+| `edit-ack-expanded` | 0.000033 s | 0.000016 s |+| `edit-ack-collapsed` | 0.000006 s | 0.000006 s |+| `complete-preview-expanded` | 0.082584 s | 0.096977 s |+| `complete-preview-collapsed` | 0.029928 s | 0.041257 s |++The two `complete-preview-*` arms rose 15–35% between the two runs. They project+a basis with no Works, so nothing in this change runs over them; the rise is the+host (see §4), and both remain an order of magnitude inside their 1 s budget.++## 3. `make test-performance-m4` — the full target++Run with the fix, `PERFORMANCE_LOG` collected:++```+✘ Test run with 32 tests in 5 suites failed after 1380.227 seconds with 9 issues (including 8 known issues).+```++- **32 tests**, up from the 28 CLAUDE.md records — the four new preview arms.+- **8 known issues**, exactly the steady state CLAUDE.md documents (Req 10.1's+  settling pass, Req 5.4's three capture-projection arms, Req 5.5's three+  diagnosis re-derivations, and the full-tier no-op reconcile). No known issue+  was added or retired.+- **1 real failure**, and it is not on a path this change touches:+  `capture-projection-duplicateSiteRows` measured 0.3079 s against the 0.25 s+  regression ceiling asserted outside its known-issue block. See §4.++## 4. The failing ceiling is the host, not the change++`capture-projection-*` measures `LibraryRepository.projectCapture`, which is a+store read plus `computeCaptureOutcome` → `captureWorkMatch`. None of those is in+this change's diff — the diff touches+`ComposedTeachingProjectionPlanner.project`, `IdentityFirstWorkMatchingPlanner`+and `WorkMatcher`, and capture reaches none of them.++Four runs of `M4ToleratedScalePerformanceTests` were taken to settle it — three+on this branch and one **control run with the change stashed**, on the same+host:++| arm | ceiling | branch 1 | branch 2 | branch 3 | control (no change) |+|---|---|---|---|---|---|+| `capture-projection-duplicateSiteRows` | 0.25 s | **0.3079** ✘ | 0.2185 | 0.1789 | 0.1902 |+| `capture-projection-siteMissing` | 0.25 s | 0.2488 | 0.1881 | 0.1724 | 0.1863 |+| `capture-projection-duplicateIdentity` | 0.25 s | 0.2029 | 0.2149 | 0.2073 | — |+| `diagnosis-refresh-foreground` | 0.4 s | 0.3280 | 0.3451 | 0.3358 | 0.2989 |+| `diagnosis-refresh-after-write` | 0.4 s | 0.3346 | **0.4465** ✘ | 0.3383 | 0.3046 |+| `diagnosis-refresh-duplicateSiteRows` | 0.4 s | 0.3260 | 0.3371 | 0.3229 | 0.3224 |++A **different** arm breached on each of the first two branch runs and neither+breached on the third. That is the signature of a noisy host, not a regression: a+regression moves the same arm every time. Sample spreads across this suite ran+1.2–2.8×, and the host load average was 13–29 because three sibling worktrees+were running their own `swift test` loops.++Both the control run and the branch's third run reported the same verdict:++```+━ Test run with 6 tests in 1 suite passed after 214.460 seconds with 6 known issues.   (control, no change)+━ Test run with 6 tests in 1 suite passed after 217.116 seconds with 6 known issues.   (branch, run 3)+```++Six known issues, no failure, on both. On the branch's third run every+`capture-projection` arm is at or below the control's number.++**Owed:** one `make test-performance-m4` on a quiet machine to confirm the whole+target exits 0 in one pass. Each suite has now been shown to pass individually,+but the 32-test run that failed has not been repeated end to end. CLAUDE.md's own+guidance applies — "Do not treat a single run as a baseline or a single failure+as a regression."++## 5. `make test-core`++```+[exited with code 0]+```++A later re-run exited 2 on the two live Apple Intelligence calls+(`FoundationCharacterExtractionModelClient`, `FoundationRuleSuggestionModelClient`)+with a `GenerationError`. Both passed on an immediate re-run of just those+suites, and neither is in AsterismCore:++```+✔ Test "A live model call returns a decodable ExtractionResult for one note" (33.499 seconds)+Suite "FoundationCharacterExtractionModelClient" passed after 33.518 seconds+✔ Test "A live model call returns a decodable RuleProposal for a two-example corpus" (19.559 seconds)+Suite "FoundationRuleSuggestionModelClient" passed after 19.561 seconds+```++The on-device model was being contended for by the sibling worktrees at the time.++The new correctness tests all pass:++```+✔ Test "The initial-teaching basis reaches no Work-matching arm at all" passed after 0.023 seconds.+✔ Test "The title-matching basis reuses all 1,000 existing Works by exact title" passed after 5.030 seconds.+✔ Test "The identity-matching basis reuses all 1,000 existing Works by URL identity" passed after 15.417 seconds.+✔ Test "A single-Entry Work-bearing preview still matches against all 1,000 Works" passed after 0.025 seconds.+```++(Those are debug timings, and they are their own small confirmation of the bug:+the Work-free basis projects in 0.367 s where the identity-bearing one takes+15.4 s.)

Things to double-check

The owed quiet-host make test-performance-m4 run.

The one full run on the branch reported 32 tests, 8 known issues, and one regression-ceiling failure on capture-projection-duplicateSiteRows (0.308 s vs 0.25 s) at load 13–29. The host-noise argument is sound — a different arm breached on each of the first two runs, none on the third, the control run with the change stashed is in band, and projectCapture reaches none of the changed code — but no 32-test run has yet exited 0 end to end. The PR states this honestly; it is the owner's run before merge.

Eager validation on a diagnosed library.

The newly-refused shape is real in the app: a Work with a blank display title on an identity-bearing Site whose Entries are all manual/unattached (or nameless with no identity). Previously the preview projected; now it reports "Unable to generate preview. Library unchanged." and RuleSuggester fails on that library. This is the better answer (the validator flags the Work) but it is a user-visible change worth a decision-log line.

Identity reachability test can pass through the wrong arm.

If a future change made derivedWorkIdentity nil on the identity basis, identityMatchBasisReusesEveryWork would still pass via title fallback and the budget arm would silently measure the wrong path — the very failure mode this ticket is about. Asserting the assignment kind closes it.

Debug-time ratio on the new arms.

The Work-free 5,000-Entry projection is ~4× slower in debug than release (0.37 s vs 0.09 s); the new title arm is ~60× (5.0 s vs 0.083 s) and identity ~80× (15.4 s vs 0.19 s). Measured under load, so possibly noise; if it reproduces on a quiet host, ExactScalarString.hash(into:) under -Onone is the suspect. Shrinking the reachability tests' Entry count sidesteps it either way.