PR #65, round 2. Round 1 (on cb4d92c) returned Ready to push with minors; this round reviews the two commits since — 078fe8c (title index gated on !identityBearing, the two reachability tests pinned to exact .reuse(workID:) per Entry, eager validation recorded in source, doc corrections) and b62a84c (the owed quiet-host make test-performance-m4 run recorded) — against the whole diff to origin/main (merge base 30c1573). No fixes were applied: the review ran under a no-modification constraint. The round-1 page is preserved as a -round1.html sibling.
WorkMatcher.match(index:) is reachable only with identityBearing == false; titleIndex is built from basis.works in exactly that case and from [] otherwise, mirroring identityIndex. The identity planner's own title fallback reads its own byMatchingTitle bucket, never WorkMatchIndex..protected/.noChange" is slightly broad (a .noChange Entry that extracted an identity already validated pre-change), and the Work has a blank display title and no parsed title..reuse(workID:) per Entry plus derivedWorkIdentity nil (title basis) / non-nil (identity basis). Still open: editAckBasesCarryEveryWork — the pin for the two timed edit-ack arms — asserts only projectedWorkID != nil. Optional simplification: entry.previousWorkID already carries the expected id, so the namespace-10/11 helper can go.078fe8c precedes b62a84c; 17:53 + build + 1,120 s lands at the log's mtime.measurements.md §6 says the four arms are "5–10% faster" than §1's after column; §1's own numbers give 16%, 21%, 12%, 7%. Direction and conclusion stand; the range does not.library-integrity-tolerance); CLAUDE.md still points only at work-and-reading-status §4 for the arms' numbers. Seven deliberately not taken up — listed as follow-ups.make verify-identity exit 0; swift test --no-parallel over AsterismCore passed: 2358 tests in 230 suites, 91 s. First run: one flaky failure in untouched BootstrapActionTests (digest-of-temp-dir around a refused open, host load 12), green in isolation and on re-run. Working tree unchanged. make test-performance-m4 not run here — the author's quiet-host log was verified instead.Ready to push
No blockers, no majors. The round-2 gating is correct on every path: identityBearing is computed once in project and passed unchanged; in assign the .protected return, the identityReuse branch (two cases, both return), the blank-name .noChange guard and the identity-first switch (four cases, no default, every arm returns) all precede WorkMatcher.match(index:), which is therefore reached only when identityBearing == false — exactly when titleIndex was built from basis.works. The two ternaries are mirror images, the identity planner never touches WorkMatchIndex, and empty construction of either index is free. The eager-validation source comment is accurate: IdentityFirstWorkCandidateIndex(validating:) is the throwing step and the blank-matching-title check is the only one that can newly fire. The strengthened tests now discriminate the arms: exact .reuse(workID:) per Entry excludes .protected/.claim, and derivedWorkIdentity nil/non-nil is what separates title fallback from identity reuse. Every number the round-2 docs cite reconciles with the log — 1,120.572 s, 32 tests in 5 suites, 8 known issues, EXIT=0, all four new-arm medians and p95s, and the eight known-issue values — and the 2026-08-30 "1,120 s" figure traces to character-ranking/verification-run.md (1,120.202 s), so "the same figure" holds. What remains is minors: one wrong derived figure in measurements.md §6 ("5–10% faster" is 7–21% by §1's own table), two round-1 items only partly taken up (no decision-log row for eager validation; CLAUDE.md still lacks the measurements.md pointer testing.md received), the edit-ack reachability pin still asserting only != nil, and prose precision in the new comment and the CLAUDE.md bullet. None blocks a merge. Verification: make verify-identity exit 0; the AsterismCore suite passed (2358 tests, 230 suites). A first run tripped one test in BootstrapActionTests — a file this PR does not touch, on a host at load 12 — which passed in isolation through make test-core and on the re-run this page reports.
Pass rate: 100% (2323 of 2323)
New tests: 11
Diff coverage: 90% (357 of 396 added lines)
a49f60a T-1910: Give the M4 preview budget a Work-bearing basis, and make it fit cb4d92c T-1910: Address review — blank guards first, and pin the index paths 078fe8c T-1910: Address review minors — gate the title index, pin the arms, fix the docs b62a84c T-1910: Discharge the owed quiet-host make test-performance-m4 run 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.
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.
Two follow-up commits. The first stops the app from building a lookup table it never reads: when a website's rules identify Works by URL, the title-based lookup is skipped entirely, so building its table was wasted effort on every keystroke. The same commit made the tests stricter — instead of checking that each saved page ended up attached to some existing Work, they now check it was attached to exactly the right one, and by the right method. The second commit records a clean run of the 21-minute performance check on a quiet machine, replacing the earlier numbers that were taken while the computer was busy with other work.
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.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.
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.ComposedTeachingProjection.project: titleIndex is built from basis.works only when !identityBearing, mirroring identityIndex. assign is identity-first and its four-case switch returns on every arm, so WorkMatcher.match(index:) is unreachable on identity-bearing Sites; the unconditional build was ~1,000 hashes per keystroke for nothing.M4ScaleFixtureTests: expectedWorkIDByEntryID maps each Entry (namespace 11) to its Work (namespace 10, index ÷ 5); both reachability tests assert assignment == .reuse(workID:) per Entry, and derivedWorkIdentity nil on the title basis / non-nil on the identity basis, which is what separates the arms.make test-performance-m4 run — 1,120.572 s, 32 tests, 8 known issues, exit 0 — is recorded in measurements.md §6; CLAUDE.md and testing.md cite it and label the 1,380 s as a loaded-host figure.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).
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.
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.M4ScaleFixtureTests reachability tests add roughly 20 s of debug time to make test-core (5.0 s + 15.4 s per measurements.md §5).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.Gating proof. identityBearing = request.urlDefinition?.suppliesIdentity ?? false is computed once (:441) and threaded to assign. Paths: (1) .protected before either index; (2) identityReuse(index: identityIndex) guarded on the flag, returning on both IdentityReuseOutcome cases or falling through on nil; (3) blank-name guard → .noChange; (4) if identityBearing → exhaustive switch over IdentityFirstWorkMatchOutcome's four cases, each returning; (5) WorkMatcher.match(index: titleIndex). (5) is reached iff the flag is false, which is iff titleIndex was built from basis.works. The identity planner's nil-identity fallback reads its own byMatchingTitle bucket, so no cross-index leak. Empty construction: validate([]) is a no-op loop, no logging; WorkMatchIndex.init cannot throw. The proof is compiler-checked against new enum cases (no default), not against a future break.
Eager validation, precisely. Duplicate Work ids are refused earlier by validate(basis:request:); invalid identity tuples by URLSiteEvidenceBasis.init, which runs first on every identity-bearing Site; evidence is the planner's own output and cannot fail validateEvidence. The blank-matching-title check is the only one that can newly fire, and only where no Entry reached a matcher pre-change: Entries that are .protected, or .noChange with no identity extracted (a .noChange Entry that did extract one already validated the list through identityReuse). The comment's '.protected/.noChange' is therefore slightly broad.
Assertion strength. ComposedAssignmentProjection is synthesized-Equatable, so == .reuse(workID:) compares case and payload; .protected and .claim(workID:) fail it. On the identity basis a .reuse with derivedWorkIdentity != nil can only come from identityMatchOutcome — with an identity present, match proceeds to claim/create rather than title reuse. The remaining gap is editAckBasesCarryEveryWork, which still asserts != nil for the two timed single-Entry arms.
Numbers. Every value in the round-2 docs was checked against the log line by line; all match. The one derived figure that does not is §6's '5–10% faster' (actual 7–21% from §1's after column).
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)
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
Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift
Why it matters. Round 2 gates titleIndex on !identityBearing, mirroring identityIndex. Verified on every path through assign: the .protected return, the identityReuse branch (two cases, both return), the blank-name .noChange guard and the exhaustive four-case identity-first switch all precede WorkMatcher.match(index:), so the title index is consulted only when it was built from basis.works. The comment now also records the eager-validation behaviour change and its reader-visible effect.
What to look at. ComposedTeachingProjection.swift:460-488 (indices and comment), 652-690 (assign)
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
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift
Why it matters. Round 2 replaces 'projectedWorkID ∈ basis.works' — which .protected and title fallback both satisfy — with the exact .reuse(workID:) per Entry via expectedWorkIDByEntryID, plus derivedWorkIdentity == nil on the title basis and != nil on the identity basis. That pair is what makes an arm swap fail. Two things remain: editAckBasesCarryEveryWork (the pin for the two timed edit-ack arms) still asserts only != nil, and the helper re-derives the fixture's namespace-10/11 layout that entry.previousWorkID already carries.
What to look at. M4ScaleFixtureTests.swift:70-133 (round 2), 136-151 (edit-ack pin, unchanged)
CLAUDE.md
Why it matters. b62a84c records the run: 1,120.572 s over 32 tests, 8 known issues, EXIT=0, no sibling test runs, and the capture-projection-duplicateSiteRows breach not recurring (0.1797 s vs 0.25 s ceiling). Every cited value matches the log. CLAUDE.md and testing.md now label the 1,380 s as a loaded-host figure. Remaining: the '5–10% faster' range in §6 is 7–21%; CLAUDE.md still lacks the measurements.md pointer; the CLAUDE.md parenthetical now holds three sentences and 'most of it' dangles; 'all quiet-host runs' for 08-28/08-30 is an inference.
What to look at. CLAUDE.md:61; docs/agent-notes/testing.md:218-229; measurements.md §4 close and §6; report.md:199-211
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.)
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.)
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.)
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.)
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.)
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.) Round 2: the source comment at ComposedTeachingProjection.swift:472-481 now records the change and its reader-visible effect (a diagnosed library still reaches teaching, so the user sees "Unable to generate preview" where a projection appeared). No decision-log row was added; report.md:140-146 still says the validator "already rejects" the shape, which is looser than the comment.
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.)
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.)
Every case of the identity-first switch in assign returns, so WorkMatcher.match(index:) is unreachable on an identity-bearing Site; building the index there hashed the Site's whole Work list per keystroke for an answer nothing read. Gated the way identityIndex already was, so the two ternaries are mirror images. (Commit 078fe8c; code comment.)
The comment sits beside the code that makes the change and names the one newly-refused shape and its user-visible effect. (Commit 078fe8c.) The review's view: repo precedent also records bugfix behaviour changes in the parent feature's decision log (library-integrity-tolerance Decisions 12 and 13, from T-1959 and T-1949); a Quick Decisions row in unified-teaching-composition/decision_log.md would complete it.
Membership in basis.works is satisfied by .protected (the Entry's existing id passes straight through) and, on the identity basis, by title fallback (the Works carry the same titles). Exact .reuse(workID:) plus derivedWorkIdentity nil/non-nil is what makes an arm swap fail. (Commit 078fe8c; helper doc comment.)
The quiet-host run measured 1,120.6 s over 32 tests — the same as the 28-test run of 2026-08-30 — so the four added arms sit inside run-to-run noise and "~21 minutes" holds; the loaded figure stays as a caution. (Commit b62a84c.) The review notes "cost no measurable wall time" overstates slightly: the four arms' samples sum to ~5 s, 0.5% of the run.
Not stated in a commit; inferred from the four the commit message enumerates. The rest — the two-candidate .ambiguous ordering test for the title-fallback and claim arms, the ~20 s the 5,000-Entry reachability tests add to make test-core, the three copies of the matching-title rule, captureBasis() duplicating the new helpers, the public candidates array kept for a log count, the TitleProjectionPlanner hoist, and the ExactScalarString.hash pre-pass — are each small and none affects correctness.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | measurements.md:187 §6 — '5–10% faster' | The claim that all four new arms are 5–10% faster than §1's after column, 'by about the same amount', is wrong by §1's own table: edit-ack-title 0.000702→0.000587 = 16%, edit-ack-identity 0.004372→0.003448 = 21%, complete-preview-title 0.083442→0.073705 = 12%, complete-preview-identity 0.189502→0.176849 = 7%. Only one arm is inside the stated range. Direction and the 18%-of-budget figure (17.7%) are right. | Say '7–21% faster (the two single-Entry arms moved most, as index construction dominates them)' or drop the range. Not applied (no-modification review); left for the author. |
| minor | Round-1 item 4 — decision log | Eager validation is now recorded in the source comment (ComposedTeachingProjection.swift:472-481) but in no decision log: grep for 'eager' / 'T-1910' across specs/*/decision_log.md finds nothing relevant. The repo records bugfix behaviour changes in the parent feature's log (library-integrity-tolerance Decisions 12 and 13 from T-1959 and T-1949). Lazy-on-first-match was the shipped behaviour and could have been kept by building the index lazily, so this was a real fork with a user-visible consequence. | One Quick Decisions row (Q31) in specs/unified-teaching-composition/decision_log.md citing the report and the source comment. Not applied (no-modification review); left for the author. |
| minor | Round-1 item 5 — CLAUDE.md:61 pointer | testing.md:228 gained 'See measurements.md §6'; CLAUDE.md:61 still says 'See specs/work-and-reading-status/verification-run.md §4 for the current numbers', which has no rows for the four T-1910 arms. | Append 'and specs/bugfixes/m4-fixture-work-matching/measurements.md §6 for the T-1910 arms' to the same clause. Not applied (no-modification review); left for the author. |
| minor | report.md:140-146 vs the new source comment | The report still says 'every shape it refuses is one the library validator already rejects', which reads as no reader-visible change. The round-2 source comment is the accurate version: since library-integrity-tolerance a diagnosed library still reaches teaching, so the reader now sees 'Unable to generate preview' where a projection appeared. | Align the report paragraph with the comment; it is also the sentence a decision-log row would carry. Not applied (no-modification review); left for the author. |
| minor | docs/agent-notes/testing.md:229 | The inserted text ends 'all EXIT=0 with the same eight known issues' and the pre-existing next sentence says 'It reported four or five known issues'. The staleness predates this PR, but the round-2 edit now sits directly above it and makes the contradiction glaring. | Fix the count or fold the sentence into the new one. Not applied (no-modification review); left for the author. |
| minor | M4ScaleFixtureTests.swift:136-151 editAckBasesCarryEveryWork | Still asserts only projectedWorkID != nil for the single-Entry bases. This is the reachability pin for the two timed edit-ack arms, so it is the one the edit-ack perf claims rest on, and the exact value is available: Entry 0 belongs to m4FixtureUUID(namespace: 10, index: 0). | Assert assignment == .reuse(workID:) on both bases and derivedWorkIdentity != nil on the identity one, as the two full-basis tests now do. Not applied (no-modification review); left for the author. |
| minor | M4ScaleFixtureTests.swift:70-85 expectedWorkIDByEntryID | The helper re-derives the fixture's namespace-10/11 layout and entryIndex / m4FixtureEntriesPerWork arithmetic. Every projected Entry already carries the same value as previousWorkID (populated from the basis Entry's workID, which taughtEntryBasis sets from the identical arithmetic). Reading it keeps the expected id independent of the projection under test — the basis is its input — and drops a second copy of the fixture layout that would need syncing if it changed. The exact-case assertion, not the id's origin, is what excludes .protected. | guard let own = entry.previousWorkID else { return false }; return entry.assignment == .reuse(workID: own) in both closures; delete the helper. Optional. Not applied (no-modification review); left for the author. |
| minor | ComposedTeachingProjection.swift:620-649 deriveURLIdentity / identityFirstCandidates | deriveURLIdentity walks basis.works to build evidenceByWorkID (populated for every Work at :626), and identityFirstCandidates walks basis.works again to look each one back up; the ?? .noEntries fallback at :647 is dead. Pre-existing shape, not introduced in round 2; on the keystroke path it is a few hundred microseconds of the 3.4 ms identity edit-ack. | Have deriveURLIdentity emit the candidate array alongside the projections, or fold the two loops; the dictionary disappears. Follow-up. Not applied (no-modification review); left for the author. |
| nit | ComposedTeachingProjection.swift:476-478 comment precision | 'whose Entries are all .protected/.noChange' is slightly broad: a .noChange Entry that did extract an identity already called identityReuse(candidates:) pre-change, which validated the whole list. The newly refused shape is Entries that are .protected, or .noChange with no identity extracted. And 'blank-display-title Work' is one with a blank display title and no nonblank lastParsedTitle (identityFirstCandidates falls back only then). | Tighten both phrases. Not applied (no-modification review); left for the author. |
| nit | CLAUDE.md:61 prose | Parentheses balance, but the parenthetical now holds three full sentences and closes with 'Add a ~190 s release build to any of them): most of it is the worst-case…' — 'most of it' refers to '~21 minutes' five clauses earlier and now reads as though most of the build is the consolidation. | Move the loaded-run and build sentences out of the parenthetical, or restore 'plus a ~190 s release build)' as its closing clause. Not applied (no-modification review); left for the author. |
| nit | CLAUDE.md:61; testing.md:224 — 'all quiet-host runs' | Only the 2026-09-05 run is evidenced as quiet. The 2026-08-30 record (character-ranking/verification-run.md:82-85) describes 'scheduling noise on a shared host'; the 1,093 s figure has no verification-run record at all. | 'Comparable runs' is the honest phrase. Not applied (no-modification review); left for the author. |
| nit | CLAUDE.md:61; measurements.md §6 — 'no measurable wall time' | The four arms' timed samples alone sum to ~5 s (20 × 0.255 s) plus fixture construction — 0.5% of 1,120 s, inside the rounding of the 08-30 figure, so the conclusion holds, but 'no measurable' overstates it. | 'Within run-to-run noise'. Not applied (no-modification review); left for the author. |
| nit | measurements.md:11, :192; implementation.md:233 | §1's 'running their own swift test loops throughout' is now contradicted by §6 (the line-8 banner covers it, but 'throughout §1–§5' would remove the ambiguity); 'the other 28 arms' should be 'tests' (the log emits far more than 32 ASTERISM-PERF labels); implementation.md's '0.19 s after' is the loaded §1 figure while §6 has 0.177 s, so the bugfix docs carry two 'after' figures. | Three one-word edits. Not applied (no-modification review); left for the author. |
| nit | M4ScaleFixture.swift:161-178 taughtEntryBasis vs entryBasis(:62-72) | Same workIndex/chapter arithmetic and the same id/captureTitle/hostname/firstCapturedAt/chapterTitle fields; only rawURLString, workID and provenance differ. Not in round 1's list (which had captureBasis vs the rule/work helpers). | One entryBasis(entryIndex:rawURL:workID:provenance:) serving all three callers. Structural only. Not applied (no-modification review); left for the author. |
| minor | Carried over from round 1 (7 minors, not taken up) | Deliberately left by the author per commit 078fe8c's 'four of them': two-candidate .ambiguous ordering test for the title-fallback and claim arms (URLIdentityPlannerTests); ~20 s added to make test-core by the two 5,000-Entry reachability tests; three copies of the matching-title rule (WorkMatcher.matchingTitle(of:), identityFirstCandidates, captureWorkMatch); captureBasis() duplicating titleRuleBasis/urlRuleBasis/workBasis; IdentityFirstWorkCandidateIndex.candidates public array kept for a log count; TitleProjectionPlanner per-call scan with a stale 'mutable list' comment; ExactScalarString.hash(into:) O(n) pre-pass. | Follow-up commit or ticket; none affects correctness or a budget. Not applied (no-modification review); left for the author. |
Source: local run at 2026-09-05T18:32:49+10:00 · snapshot b62a84ccf95b52d0e383d9d196f88157a39eb133
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
Derived by declaration name, from the diff (no baseline run).
Aggregate diff coverage: 90% (357 of 396 measurable added lines).
Head 93.7% (77973 of 83181 lines)
9 of 14 changed files matched coverage data.
CLAUDE.md — no candidatedocs/agent-notes/testing.md — no candidatespecs/bugfixes/m4-fixture-work-matching/measurements.md — no candidatespecs/bugfixes/m4-fixture-work-matching/report.md — no candidatespecs/unified-teaching-composition/implementation.md — no candidateFiles 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.
Click to expand.
diff --git a/CLAUDE.md b/CLAUDE.mdindex 32fee26..69824ea 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, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): 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 on a quiet host**, 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**
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swiftindex ed826f6..8c2520d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ComposedTeachingProjection.swift@@ -456,12 +456,36 @@ 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.82 s against Req 8.5's 1 s preview budget, for work whose+ // answer cannot change across the loop.+ //+ // Each index is built only for the arm that can consult it: `assign` is+ // identity-first, and every case of its identity-first switch returns,+ // so `WorkMatcher.match(index:)` is unreachable on an identity-bearing+ // Site. Building the title index there would hash the Site's whole Work+ // list per keystroke for an answer nothing reads.+ //+ // Behaviour change, deliberate (T-1910): validation is now **eager**.+ // `IdentityFirstWorkCandidateIndex(validating:)` throws before the loop,+ // so an invalid Work list is refused even when no Entry would have+ // reached a matcher — refusal no longer depends on which Entries happen+ // to reach an arm. The one shape newly refused is an identity-bearing+ // Site holding a blank-display-title Work whose Entries are all+ // `.protected`/`.noChange`; `LibraryValidator` already diagnoses that+ // Work, but since `library-integrity-tolerance` a diagnosed library+ // still reaches teaching, so the reader now sees "Unable to generate+ // preview" there rather than a projection.+ let identityIndex = try IdentityFirstWorkCandidateIndex(+ validating: identityBearing+ ? identityFirstCandidates(basis: basis, evidenceByWorkID: evidenceByWorkID)+ : [])+ let titleIndex = WorkMatchIndex(candidates: identityBearing ? [] : basis.works.map { WorkMatchCandidate(id: $0.id, lastParsedTitle: $0.lastParsedTitle, displayTitle: $0.displayTitle)- }+ }) var entryProjections: [ComposedEntryProjection] = [] var prospectiveEntries: [ProspectiveWorkEntry] = []@@ -469,7 +493,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 +651,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 +662,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 +674,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 +682,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))
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.
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 {
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")
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).
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex a7af7db..49c97ae 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -55,6 +55,101 @@ 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)+ }++ /// The Work each fixture Entry belongs to, keyed by Entry id. `assign` is+ /// the only thing under test here, and `projectedWorkID ∈ basis.works` is+ /// too weak to pin it: `.protected` carries the Entry's existing Work id+ /// straight through, and on the identity basis the Works also carry the+ /// titles the fallback arm matches on. Asserting the exact+ /// `.reuse(workID:)` per Entry is what makes an arm swap fail.+ private func expectedWorkIDByEntryID(entryCount: Int) -> [UUID: UUID] {+ var expected: [UUID: UUID] = [:]+ for entryIndex in 0..<entryCount {+ expected[LibraryRepository.m4FixtureUUID(namespace: 11, index: entryIndex)] =+ LibraryRepository.m4FixtureUUID(+ namespace: 10,+ index: entryIndex / LibraryRepository.m4FixtureEntriesPerWork)+ }+ return expected+ }++ @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 Entry carries the exact `.reuse` its own Work.+ #expect(outcome.prospectiveWorks.isEmpty)+ let expected = expectedWorkIDByEntryID(entryCount: fixture.entryCount)+ #expect(outcome.entries.allSatisfy { entry in+ guard let workID = expected[entry.entryID] else { return false }+ return entry.assignment == .reuse(workID: workID)+ })+ // Sequence-only rule: nothing derives a Work identity, so the title arm+ // is the only one that could have produced those reuses.+ #expect(outcome.entries.allSatisfy { $0.derivedWorkIdentity == nil })+ #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)+ // Every Entry extracted its Work's slug and reused that exact Work.+ // Without the identity, the same ids would come back through title+ // fallback and this test would pass through the wrong arm.+ #expect(outcome.entries.allSatisfy { $0.derivedWorkIdentity != nil })+ let expected = expectedWorkIDByEntryID(entryCount: fixture.entryCount)+ #expect(outcome.entries.allSatisfy { entry in+ guard let workID = expected[entry.entryID] else { return false }+ return entry.assignment == .reuse(workID: workID)+ })+ }++ @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")
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")
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
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,
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex be5cd09..c51d543 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -218,9 +218,14 @@ Consequences: (`specs/retire-migration-chain/verification-run.md`): the target **exits 0**. A `RUNS=1` pass took ~20 minutes then, **~25 minutes** after `multi-site-works` 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).+since `drop-superseded-columns` (1,093 s of test time measured 2026-08-28,+1,120 s over 28 tests measured 2026-08-30 after `character-ranking` added its+own, and **1,120.6 s over 32 tests measured 2026-09-05** after T-1910 added four+Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight+known issues, so the four added arms cost no measurable wall time. A *loaded* run+of those same 32 tests took 1,380 s and breached a regression ceiling on an+untouched arm; that is host contention, not a band. See+`specs/bugfixes/m4-fixture-work-matching/measurements.md` §6). 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 +270,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
diff --git a/specs/bugfixes/m4-fixture-work-matching/measurements.md b/specs/bugfixes/m4-fixture-work-matching/measurements.mdnew file mode 100644index 0000000..ad1d9ad--- /dev/null+++ b/specs/bugfixes/m4-fixture-work-matching/measurements.md@@ -0,0 +1,197 @@+# 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`).++**§1–§5 were measured on a loaded host; §6 is the quiet-host re-run that+settles them.**++**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.++**Confirmed by the quiet-host run (§6):** the whole target exits 0 in one pass,+and `capture-projection-duplicateSiteRows` came back at 0.1797 s — below the+control run's 0.1902 s and well inside the 0.25 s ceiling. CLAUDE.md's own+guidance held: "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. They are in the package's `AsterismIntelligenceTests` target, not+`AsterismCoreTests` — `make test-core` is a bare `swift test` over the whole+`AsterismCore` package, so it runs them, which is why the re-run exited 2:++```+✔ 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.)++## 6. Quiet-host run — the owed run, discharged++`make test-performance-m4` on head `078fe8c`, started 17:53 on 2026-09-05. Load+average ~8 at the start and falling to idle; **no sibling test runs on the host**,+unlike every run in §1–§4.++```+━ Test run with 32 tests in 5 suites passed after 1120.572 seconds with 8 known issues.+EXIT=0+```++- **`EXIT=0`** in one pass, end to end. The `capture-projection-duplicateSiteRows`+ ceiling breach of §3/§4 did **not** recur (0.1797 s against a 0.25 s ceiling).+- **1,120.6 s** of test time over **32** tests — the same figure as the 28-test+ quiet-host run of 2026-08-30, so the four added arms cost no measurable wall+ time and CLAUDE.md's ~21 minutes reconciles again. The 1,380 s of §3 was the+ loaded host.+- **8 known issues**, exactly the set CLAUDE.md lists and unchanged from §3:+ Req 10.1's settling pass (7.650 s vs a 2 s budget), Req 5.5's three diagnosis+ re-derivations (0.2973 / 0.2927 / 0.2930 s vs 0.25 s), Req 5.4's three+ capture-projection arms (0.1797 / 0.1773 / 0.1855 s vs 0.1 s) and the+ full-tier no-op reconcile (0.0305 s vs 0.01 s). None added, none retired.++The four arms this change adds:++| arm | median | p95 | budget |+|---|---|---|---|+| `edit-ack-title-matching` | 0.000587 s | 0.000603 s | 100 ms |+| `edit-ack-identity-matching` | 0.003448 s | 0.003569 s | 100 ms |+| `complete-preview-title-matching` | 0.073705 s | 0.075344 s | 1 s |+| `complete-preview-identity-matching` | **0.176849 s** | 0.181842 s | 1 s |++All four are 5–10% *faster* than the §1 "after" column measured under load, in+the same direction and by about the same amount — the loaded run's numbers were+already honest, just taxed. `complete-preview-identity-matching`, the arm the fix+is anchored to, sits at 18% of its budget against 5.82 s before the fix.++**The other 28 arms: unchanged within spread.** Nothing moved further than the+host tax already documented in §2 and §4 — the two Work-free `complete-preview`+arms come back down to their pre-change values (`complete-preview-expanded`+0.0803 s, `complete-preview-collapsed` 0.0300 s, against 0.0970 s and 0.0413 s+under load), and the diagnosis-refresh arms land at 0.293–0.297 s, at or below+the §4 control run. No arm reached a regression ceiling.
diff --git a/specs/bugfixes/m4-fixture-work-matching/report.md b/specs/bugfixes/m4-fixture-work-matching/report.mdnew file mode 100644index 0000000..544d62a--- /dev/null+++ b/specs/bugfixes/m4-fixture-work-matching/report.md@@ -0,0 +1,249 @@+# 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; they live in the package's+ `AsterismIntelligenceTests` target rather than `AsterismCoreTests`, and+ `make test-core` runs the whole package — 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] **The owed quiet-host run is done** — `make test-performance-m4` on head+ `078fe8c`, no sibling test runs: `EXIT=0`, 32 tests in 5 suites passed+ after **1,120.572 s** with the same **8 known issues**, and the+ `capture-projection-duplicateSiteRows` breach did not recur. Numbers in+ `measurements.md` §6.+- [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.++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".
diff --git a/specs/unified-teaching-composition/implementation.md b/specs/unified-teaching-composition/implementation.mdindex 4d94330..b2b05d0 100644--- a/specs/unified-teaching-composition/implementation.md+++ b/specs/unified-teaching-composition/implementation.md@@ -225,6 +225,14 @@ types, with the top-level names as typealiases. This is recorded in built with `works: []`, so the Work-matching paths in the projection planner are not exercised by the budget test. Re-teaching a site with many Works is the untested-for-performance case.+ - **Superseded 2026-09-05 by T-1910** (`specs/bugfixes/m4-fixture-work-matching/`).+ The gap was real and the untested path was over budget: `M4ScaleFixture`+ gained `titleMatchBasis` / `identityMatchBasis` (the Site's full 1,000 Works+ on every projection) and four Req 8.5 budget arms now measure them. The+ identity-bearing complete preview measured 5.82 s against the 1 s budget+ before the planner hoisted candidate validation and bucketing out of the+ per-Entry loop; it is 0.19 s after. `composedBasis` is deliberately+ unchanged, so the bands recorded against it still hold. - **v3 key recompute is a whole-site rewrite.** Re-teaching a title rule on a sequence-only site rewrites every Entry's key in one commit. Correct, but the cost is linear in site size and happens inside a single transaction.
'A failed open leaves the store, the marker and every artefact unchanged' (.markerThatIsNotText) failed on root.digest() == before in the first 2,358-test run, on a host at load 12. The file is untouched by this PR (last changed in #60), the same suite passed in round 1 on this branch, the suite passed in isolation through make test-core CORE_TEST=BootstrapActionTests, and the re-run this page reports is what the Tests section shows. It digests a temp directory around a refused open — the shape that is sensitive to a busy host — so it is flakiness, not a regression from this branch. Worth an eye if it recurs on main.
A future case added to IdentityFirstWorkMatchOutcome without a return in assign's switch would not compile (no default), so the proof is compiler-checked. But a future break or a fall-through to the title arm on an identity-bearing Site would silently consult an empty WorkMatchIndex and report .create for every Entry. Nothing pins that today; the reachability tests would catch it only for the exact fixture shape.
Unchanged from round 1 and now correctly described in the source: a Work with a blank display title (and no parsed title) on an identity-bearing Site whose Entries are all manual/unattached, or nameless with no identity, now yields "Unable to generate preview" where a projection used to appear, and RuleSuggester fails on that library. Better than projecting over a diagnosed Work, and one Quick Decisions row from being fully recorded.
complete-preview-identity-matching is 0.19 s (loaded, §1, cited by report.md and implementation.md) and 0.177 s (quiet, §6). Both are far inside 1 s and the difference is host tax; a reader comparing the two files should not take it for drift.