asterism Commits 19 Files 20 Lines +4,256 / −257 New tests ~150 Suites 1,287 core / ~500 app, green

Pre-push review: T-2135/teach-editor-authoring-gaps

Two authoring gaps closed in the teach editor — per-side URL anchoring and title trims on the positional segment forms — plus a folded-in AsterismCore fix for the Req 3.21 Work-rename inversion, and this review's own fix pass.

At a glance

  • URL side. A selected path component now shows two menu rows — Before it / After it — over a slot-aware state machine (SlotAnchoring, URLLocatorResolution, URLRuleStatus). Defaults follow a total four-outcome function; an undecided pair holds the disclosure open, names the missing side and refuses the commit.
  • Title side. A partially-kept edge segment beside at least one whole-segment marking now infers the positional form plus exact trimPrefix/trimSuffix, so tapas' Comics and Novels families parse with one rule. Reopening seeds the controls back from the stored trims behind a faithfulness guard.
  • Core fix (Q31 waiver). Work.refreshParsedTitle(to:commit:) replaces a rule that was written three times and inverted in two of them — identity-matched Works never received their new parsed title, and Recalculate's change detector was name-blind so it could not repair them either.
  • Two invariants carry the URL machine and both are pinned by tests, not prose: the slot-resolution table is total because chooseAnchor drops the retained locator inside the transition that completes the pair; the default function is total because .unauthorable is its enumerated fallback.
  • Nothing below the editor moved. No schema version, no migration, no rewritten stored rules; Req 4.2's three exceptions are the chapter default, the new title inference, and paths where the old editor authored a locator that provably could not match its own teaching URL.

Verdict

Ready to push

Every requirement is implemented and either tested or waived on the record (Q27–Q33). This review found and fixed two behavioural majors — a URL-disclosure collapse silently deferred while an anchoring was pending, and the Req 3.21 rename rule triplicated across three sites with two of them inverted — plus three documentation majors in the changelog. Both suites are green (1,287 AsterismCore, ~500 app) with zero new compiler warnings, verified against a forced recompile with a control warning rather than a warm-cache false green. The feature was exercised on the device, including the live tapas repair that exposed the rename bug in the first place.

Review findings

20 raised · 15 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

What this does

Asterism learns a website by being taught: you paste one example page, tap the meaningful parts of its title and URL, and the app derives a rule for every other page on that site. This branch fixes two places where the app could store a rule it had no way to author — no sequence of taps could express a rule the rest of the system was happy to save, apply, back up and sync.

The URL gap. A URL rule points at one piece of an address (the 12 in tapas.io/series/some-story/12) by naming its neighbours. The editor always chose the neighbours for you, and always chose the one immediately left — which on tapas is the story's own name, so a chapter rule taught on one story only worked for that story. Now two menus appear under the component you tapped and you choose. Taking the last component for a chapter defaults to “the last part of the path”, which holds across every story.

The title gap. tapas serves Read The Regressor :: Episode 4 | Tapas Comics. The word Read is boilerplate. The model has always been able to trim a fixed prefix, but the only gesture that dropped it also switched the rule to a brittle form memorising the whole tail — so the Comics family and the Novels family kept invalidating each other. Now cutting the first segment and keeping only the story-name part infers “drop Read , then read the rest positionally”, and one rule covers both. A caption says what is being dropped.

Why it matters

Two real sites were stuck. tapas chapters could not be sourced without pinning the rule to one story, and m.fanfiction.net had lost an anchor on an earlier re-teach with no gesture to put it back. Both are authorable now and both were verified on the phone.

A bug found on the device

The first live tapas repair stored a correct trimmed rule and every preview showed the trimmed name — while the Work in the library kept its old name. The code that renames an existing Work on re-teach only ran for Works with no URL identity, the exact inverse of the requirement. Fixed here, in all three places that ask the question, through one shared helper.

Words worth knowing

  • Locator — how a rule points at a URL piece: a left anchor and a right anchor, each either a literal neighbour, the path's start/end, or unanchored.
  • Slot — the editor has two: Work identity and chapter sequence.
  • Trim — fixed text sliced off a title's front or back before any rule runs.
  • Pending — the new “you must decide” state: where several valid anchorings exist and mean different things, the editor refuses to guess and blocks Save until you pick.
  • Retained locator — when a stored rule cannot be shown on the current URL, the editor keeps the stored locator itself, so unrelated gestures republish it exactly instead of quietly replacing it with a guess.

Changes overview

  • Views/ComposedURLEditorState.swift (+603/−69) — SlotAnchoring, URLLocatorResolution, URLRuleStatus/URLRuleOutcome, TransitionRefusal; the slot-resolution table; transactional selectComponent/chooseAnchor; the default function; bracketedIndices.
  • Views/ComposedURLDetailsEditor.swift (+232/−25) — anchoring menu rows, beginGesture(), chipTap extracted as a nonisolated static, onRuleChange widened to URLRuleOutcome.
  • Views/ComposedTeachingPresentation.swift (+423/−24) — reclassification and the edge-trim branch, anchorRowState, trimCaption, the notice strings, describe(left:)/describe(right:) relocated from the view model.
  • ViewModels/ComposedTeachingViewModel.swift (+244/−37) — status plumbing, the commit gate, the disclosure-expansion authority, trimmed segment-rule seeding behind a faithfulness guard.
  • AsterismCore (Q31 only) — Work.refreshParsedTitle(to:commit:) plus its three call sites.

Implementation approach

Typed status. The editor used to publish URLRuleDefinition?, where nil meant three different things downstream (chapterUnsourced, the collapsed summary, request construction all read it as “cleared”). Q22 replaced it with enum URLRuleStatus { cleared, valid, pending(message:), unauthorable(message:) }, carried beside the split editor's own error channel in URLRuleOutcome. That single type change is what lets a half-made choice gate the commit with an explanation instead of looking like an empty selection.

Slot resolution. Each slot holds SlotAnchoring { left, right, retainedLocator, edited } and resolves through one table: retained-and-unedited republishes the stored locator byte-identically; retained-and-edited with an incomplete pair goes .pending; no retained locator falls to the default function; a blank component is .unauthorable.

The default function enumerates each free side's offered anchors, keeps candidates that are both representable (asked of core's own validate) and resolve uniquely at the selected index, then picks the preferred derivation, else the sole survivor, else .pending, else .unauthorable. Decision 4's fix lives in preferredAnchoring: the left scan no longer skips blanks, so /a//b stops authoring .literal("a") — a locator that failed against its own teaching URL.

Transactional transitions. selectComponent and chooseAnchor probe before mutating and return an optional TransitionRefusal, so a refused gesture has no side effects at all — select's split-clearing and retained-template clearing included. The two grounds are told apart by which check fails: nothing representable → representation-rejected (the both-unanchored pair always lands here); representable but non-resolving → does-not-resolve.

Title pipeline. inferenceOutcome is now explicit: structural guard → Req 3.1 reclassification (a fully-kept subdivided segment collapses back to a whole-segment chip, punctuation included) → semantic guards → unchanged early branches → edgeTrimRule → unchanged remaining branches. The edge-trim branch slices exact trims, re-tokenizes the trimmed title, guards it (same segment count, interior segments unchanged, trimmed edges equal to their kept runs) and derives the positional definition from the post-trim texts. Any guard failure falls through, so a selection never becomes dead.

Seeding round-trip. Stored trims are located with TitleTrimApplicator.keptCharacterRange (never hasPrefix), mapped onto part-chip boundaries, the anchors inverted against the post-trim segment count — then the whole inference is run back over the reconstruction and required to equal the stored rule under RuleDefinitionComparator. On mismatch the editor keeps its default selection and raises a stored-rule notice.

Trade-offs

  • Full control vs. the narrow default (Decision 1): ten lines would close tapas alone, but m.fanfiction.net's right anchor needs a real control, and doing them separately means visiting the highest-regression surface in the app twice.
  • Pending vs. auto-picking (Decision 5): several valid anchorings encode a genuine question — which neighbours are stable, which are story-specific — that the Non-Goals reserve for the reader. A priority order would auto-author exactly the over-pinned rule class this feature exists to eliminate.
  • Retention vs. a warning flag (Decision 6): a flag loses the value, so any unrelated gesture would re-dispatch and replace it, and a stored .workAndSequence with a hidden work locator would narrow to .sequence — the defect class that once moved 40 captures off their keys.
  • Edge-only trims (Decision 2): full mixed granularity grows the inference table combinatorially for a case nobody has hit.
  • Restating core's predicate in the app (Q20): buys an empty core diff at the cost of duplication, paid for with a differential test rather than with trust.

Technical deep dive

Totality and unreachability as obligations. The slot-resolution table is total because chooseAnchor drops the retained locator inside the same transition that completes the pair, making “retained ∧ both sides in force” unreachable; the default function is total because .unauthorable is its enumerated fallback. defaultFunctionTotality sweeps generated paths for exactly one outcome and checks every .locator against core's select. Q32 records honestly that its preferred helper transcribes preferredAnchoring line for line — it catches refactoring drift, not a misreading; the independent core check and the separate differential test are the load-bearing halves.

The echo-suppression handshake. seededFrom stores the status but compares on the definition. Deliberate: the view model only ever hands a definition back, so a pending publication's nil would otherwise be indistinguishable from an external clear, and re-seeding on it would wipe the half-chosen anchoring mid-gesture. The review's naive “collapse cleared and pending” nit would have reintroduced exactly that; it was corrected and the reasoning left in the comment.

Notice ownership. Four channels, four owners: refusals are view @State retired by beginGesture() (one call site per gesture, replacing eight scattered clears); pending derives from URLRuleStatus; the Req 1.7 stored-anchoring notice derives from SlotAnchoring; split errors ride their own field on URLRuleOutcome precisely so a split failure publishes .cleared — commit-settled — rather than a fourth status blocking the commit on a message about an editor the reader may have left.

Collapse under pending. setURLDisclosureExpanded is the single authority behind the binding. A collapse tap while pending is ignored, not deferred: urlDisclosureExpanded holds the section open regardless, so honouring the tap would change nothing on screen while recording a collapse — and with it a didAutoExpandForChapter reading taken under the pending guard's forced chapterUnsourced == false, which would snap the section shut the instant the pair completed.

Actor isolation. The app target defaults to MainActor, so every pure static a nonisolated value-type state machine consumes must itself be nonisolated, transitively. That same requirement is what makes anchorRowState and chipTap extractable as nonisolated statics — view logic pinned by tests without a running view, and the route by which Req 1.7's display half became testable at all.

Req 3.21's detector. Recalculate now runs refreshParsedTitle(commit: false) over the target Work group, so a stale name counts as a change even when key, sequence, identity and pointer all agree. Without it Recalculate answers .noChanges over a rename it would in fact apply, and Req 3.26's “apply exactly the confirmed preview” cannot repair a Work a re-taught trim left behind.

Architecture impact

The editor's output contract widened from URLRuleDefinition? to URLRuleOutcome; setURLRuleDefinition survives only as a documented test seam for the six suites that predate the status. Every urlLocator call site moved to slot resolution — rule(in:)'s three branches, canDeclareSequenceOptional, combinedTemplateCore — so the gate the reader is offered and the rule dispatch publishes derive under one guard; the previous divergence let a non-resolving locator pass a gate dispatch refused. SlotAnchoring is the fourth piece of retained editor state beside retainedTemplate, sequencePresence and the split; a fifth would be the point to consolidate. On the title side inferenceOutcome is the seam and names the branch it took, which is what makes branch-exclusivity sweeps possible.

Potential issues

  • Q27 — a .pending slot gates the commit even when the emitted form would discard that slot (a live split authoring .combined while the sequence slot is undecided). Matches the design verbatim; the escape is clearing the URL selection.
  • Q30 — a stored rule seeding no chip in either slot shows the Req 1.7 notice with no “Clear URL selection” affordance, because selectionHints requires a live selection. The escape is any chip tap.
  • Q5 — on a trailing-slash URL the final blank component is the last component, so Req 2.1 does not fire and Req 2.3 is scoped to non-trailing-slash paths.
  • The repeated-value seeding defect stays out of scope: selection(for:in:) still seeds by first matching component text. Req 1.7's second branch is what keeps that non-destructive — the stored locator is retained, not reflected.
  • CosteffectiveTitleRule re-runs the pipeline ~10 times per tap; memoisation was considered and skipped (tens of µs against a real stale-cache risk).

Important changes — detailed

The slot-resolution state machine

Asterism/Asterism/Views/ComposedURLEditorState.swift

Why it matters. This is the feature's spine: one table decides what each slot authors, and every downstream branch — rule(in:), the optional-sequence gate, the combined-template derivation — reads it instead of re-deriving a locator. Getting it wrong re-keys captures, which is exactly the defect class that motivated the retained-template precedent it follows.

What to look at. URLEditorState.resolution(selection:slot:anchoring:in:) and the SlotAnchoring / URLLocatorResolution declarations above it.

Takeaway. The table is total by construction, not by a default case: chooseAnchor drops the retained locator inside the same transition that completes the pair, so the state "retained locator plus both sides in force" cannot exist. Read that invariant before judging any row of the table.
Rationale. Decision 6 — a hidden stored locator is retained per slot rather than flagged, because a flag loses the value and any orthogonal gesture would then re-dispatch a freshly derived default over it; a stored .workAndSequence with a hidden work locator would narrow to .sequence, the defect class that moved 40 captures off their version-2 keys. Q25 extends retention to every locator kind, query locators included.

Transactional transitions and the two refusal grounds

Asterism/Asterism/Views/ComposedURLEditorState.swift

Why it matters. Reqs 1.3 and 1.4 promise that a refused gesture leaves the previously in-force anchoring alone. select() clears the split and, off the same-component path, the retained template — so a refusal that mutated first would silently destroy state while reporting a failure.

What to look at. selectComponent(_:in:) and chooseAnchor(slot:side:anchor:in:), both probing before mutating and returning an optional TransitionRefusal.

Takeaway. The two grounds are distinguished by which check the candidates fail, not by a separate classifier: nothing representable → representation-rejected (the both-unanchored pair always lands here, resolving or not); representable but none resolving uniquely at the index → does-not-resolve.
Rationale. Q8 as amended — refusal has two grounds, and both-sides-unanchored is always the representation's, since it resolves fine on a single-component path and is refused anyway. Decision 5 makes the default function total so refusal is the honest fourth outcome rather than a fallback guess.

The default function and the blank-skipping left-scan fix

Asterism/Asterism/Views/ComposedURLEditorState.swift

Why it matters. This is where Req 2.1's chapter default lives, and where a shipped defect is retired: the old left scan skipped blank components while the applicator checks only the immediate neighbour, so on /a//b it authored .literal("a") — a locator that fails against the very URL it was taught from.

What to look at. urlLocatorResolution(for:slot:anchoring:in:), preferredAnchoring(at:slot:in:), and the bracketedIndices helper beneath them.

Takeaway. bracketedIndices restates core's own bracketing predicate inside the app because core's select returns the matched value, not its index — a locator can resolve uniquely at the wrong index of a repeated-value path. The duplication is deliberate and pinned by a differential test asserting both directions of the iff against URLRuleApplicator.select.
Rationale. Q20 — an additive core query API was rejected to keep the AsterismCore diff empty per the Non-Goals, so the predicate is restated and pinned differentially. Decision 4 fixes the scan here rather than exempting the control's own default from validation, which would have baked the defect into the new surface.

URLRuleStatus plumbing and the commit gate

Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift

Why it matters. An optional definition was the only output channel, and nil was indistinguishable downstream from a deliberate clear — chapterUnsourced, the collapsed summary and request construction all read it that way. Pending could therefore never gate the commit with an explanation.

What to look at. urlRuleStatus, urlAnchoringPendingMessage, urlRuleSettled, updateURLRule(_:), setURLDisclosureExpanded(_:), and the urlRuleSettled guard now inside buildRequest().

Takeaway. A collapse tap while pending is ignored outright, not deferred. urlDisclosureExpanded holds the section open regardless, so honouring the tap would change nothing on screen while recording a collapse — and a didAutoExpandForChapter reading taken under the pending guard's forced chapterUnsourced == false, which would snap the section shut the moment the pair completed. This review replaced the deferred version with the view-model-owned setter.
Rationale. Q22 — the editor publishes a typed URLRuleStatus instead of only an optional definition, so cleared, valid, pending and unauthorable stay distinct downstream. The gate copy-paste was collapsed into buildRequest() during this review, and setURLRuleDefinition kept only as a documented test seam for the six suites that predate the status.

Anchoring rows and anchorRowState

Asterism/Asterism/Views/ComposedURLDetailsEditor.swift

Why it matters. Req 1.7's display half is where the rule is easiest to break: a slot holding a stored locator the controls cannot show must display neither side's default, because depicting a default would present an anchoring that is not the stored one as if it were.

What to look at. anchoringSections / anchoringBlock / anchorRow, the derived-once resolution passed into both rows, and ComposedTeachingPresentation.anchorRowState.

Takeaway. The display rule was extracted as a nonisolated pure static so it could be tested without a running view — the same pattern as chipTap. This review found the display half entirely untested; it now carries five tests. Under default MainActor isolation any pure static a nonisolated caller reads must itself be nonisolated, transitively, which is what makes this extraction cheap here and expensive if retrofitted later.
Rationale. Q21 — two always-visible labelled Menu rows per selected path component, worded with the existing describe phrases (relocated to the presentation layer by Q23 so both surfaces share one wording); a disclosure would hide Req 1.1's offer and would have to force open for the undecided state anyway. The four-times-per-render re-derivation of slot resolution was collapsed to one during this review.

The edge-trim inference branch

Asterism/Asterism/Views/ComposedTeachingPresentation.swift

Why it matters. This is the whole title-side feature, and its risk is re-routing gestures that already mean something else. The pipeline is reclassify-then-infer, and every stage after reclassification sees the collapsed chip row.

What to look at. inferenceOutcome(title:segments:chips:roles:), reclassified(segments:chips:roles:) and edgeTrimRule(title:segments:chips:roles:).

Takeaway. Anchors are derived from the title as it reads after the trims are removed and re-tokenized, not from the original title's chips — deriving them from the original authors a wrong rule the moment the trimmed text contains a separator. Every guard failure falls through to the old branches, so a selection is never left dead.
Rationale. Decision 3 — the trigger fires only beside at least one whole-segment field marking, keeping the existing work-only edge-subdivision gesture's meaning intact. Decision 2 limits widening to the edges. Q18 makes a fully-kept subdivided segment count as a whole-segment marking with its punctuation. Q10 pins the post-trim re-tokenization.

Seeding faithfulness guard for trimmed segment rules

Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift

Why it matters. Req 3.8 forbids presenting a different rule as if it were the stored one. Seeding a trimmed segment rule requires locating the trims in the current title, mapping the kept boundaries onto part chips, and inverting the anchors against the post-trim segment count — three places to be subtly wrong.

What to look at. applyTrimmedSegmentSelection and trimmedSegmentSelection, plus partBoundaries(ofSegment:).

Takeaway. Rather than trusting the reconstruction, it runs the full inference back over it and requires the result to equal the stored rule — under RuleDefinitionComparator, not ==, because trims compare by exact scalars and a canonically-equal but byte-distinct definition is the same rule. On any mismatch the editor keeps its default selection and raises the stored-rule notice.
Rationale. Q15 — trims are exact edge-to-kept-run slices, so an off-by-one space must fail closed. The comparator swap and the derivation of partBoundaries from titleChips (instead of restated chip arithmetic) were both this review's findings; the comparator now matches the projection's own faithfulness check.

Req 3.21: Work.refreshParsedTitle and the name-blind change detector

Packages/AsterismCore/Sources/AsterismCore/Models.swift

Why it matters. The branch's one deliberate AsterismCore change, and the only behavioural fix outside the editor. The rename rule was written three times and inverted in two of them: identity-matched Works never received their new parsed title, so the first live tapas repair stored a correct trimmed rule while the Work kept its pre-trim name.

What to look at. Work.refreshParsedTitle(to:commit:), and its three call sites in LibraryRepository+ComposedTeaching (apply and change detector) and +ReparseCapture.

Takeaway. The commit flag is the point: the two write sites and Recalculate's read-only detector ask literally the same question, so they cannot drift. Without the detector half, Recalculate reported .noChanges over a rename it would in fact apply, and Req 3.26's "apply exactly the confirmed preview" could not repair a Work a re-taught trim left behind.
Rationale. Q31 — folded into this branch and the "no AsterismCore changes" non-goal waived for it alone, after the first on-device tapas test. The defect predates the feature; the feature was the first thing able to expose it, and a separate ticket was not worth the overhead. This review found the rule triplicated and collapsed it into the single helper.

Key decisions

Decision 1 — ship full per-side anchoring, not just the narrow default. A ten-line builder change would have closed tapas alone, but m.fanfiction.net's dropped .literal("1") right anchor needs a genuine control on the right side. Building the default now and the control later means visiting the highest-regression surface in the app twice; the amended non-goal in url-locator-generalisation set the trigger as “the first time a site genuinely needs an anchoring decision on either side”, and both sides now have a live case.
Decision 4 — the blank-skipping left scan is fixed here, not deferred again. The scan does not merely misbehave on edge cases: on /a//b it authors .literal("a"), a locator that provably fails against its own teaching URL. Three commitments could not coexist — keep the scan, offer a control whose vocabulary can express the default, and refuse candidates that do not resolve. The control would have had to display, then refuse, its own default. Cost: the change-detector tests pinning the old scan had to be updated deliberately, and Req 4.2 carries the exception.
Decision 5 — defaults follow one total four-outcome function. Today's derivation where it resolves uniquely at the selected component; else the single offered combination that does; else no default — undecided, with the commit refused; else the selection itself is refused. The “exactly one” condition is policy, not convenience: it separates “the representation leaves no choice” from “a semantic choice exists”, which the Non-Goals assign to the reader. A fixed priority order was rejected because on /a/x/a/y every valid pair pins the story-specific side — auto-authoring exactly the over-pinned rule class this feature exists to eliminate.
Decision 6 — a hidden stored locator is retained per slot, not flagged. A warning flag records only that the anchors are hidden; the value is gone, so an orthogonal gesture (presence toggle, split-token tap) that re-dispatches replaces it with a freshly derived default, and a stored .workAndSequence whose work locator seeds no chip silently narrows to .sequence. Both external reviewers converged on retention independently. Pending-on-everything was rejected as blocking edits that touch neither anchoring side.
Q20 — index-precise resolution is an editor-side helper pinned differentially. Core's select returns values, not indices, and a repeated-value path can resolve uniquely at the wrong index. An additive core query API was rejected to keep the AsterismCore diff empty per the Non-Goals, so bracketedIndices restates the predicate in the app and a differential property test asserts both directions of the iff against URLRuleApplicator.select. This is the pattern worth reusing: restate, then pin, rather than restate and hope.
Q22 — the editor publishes a typed status, not an optional definition. A nil definition is indistinguishable downstream from a deliberate clear (chapterUnsourced, the collapsed summary, request construction all read it that way), so pending could not gate the commit with an explanation. The split editor's failure rides beside the status on its own channel rather than becoming a fourth case — a split failure authors no rule, so the status is .cleared (commit-settled) and the reason is shown by the split editor and nowhere else.
Q26 — a cross-slot locator clash keeps publishing <code>.cleared</code>. Upgrading it to unauthorable would block a commit today permits, a behaviour change outside Req 4.2's exceptions. The existing hint row remains the explanation, and a change-detector test pins that this is not silently upgraded.
Q27 — a pending slot gates the commit even where the emitted form would discard it. A live split authoring .combined while the sequence slot's anchoring is undecided is still refused. This matches the design verbatim (“any slot .pending → status pending”) and Req 4.2's third exception; the escape is clearing the URL selection. Accepted consciously rather than special-casing forms that discard a slot — worth a human eye on how it reads in practice.
Q30 — a stored rule seeding no chip in either slot has no clear affordance. It renders the Req 1.7 notice, but “Clear URL selection” is gated behind a live selection by pre-existing selectionHints logic. The escape is any chip tap, which replaces the retained locator per the lifecycle table. Accepted as-is; revisit only if a real site produces that state.
Q31 — the Req 3.21 core fix is folded in and the non-goal waived for it alone. The feature's own AsterismCore diff is empty, as the Non-Goals require. The one core change on the branch is the rename fix, waived after the first on-device tapas test showed the trim stored correctly while the Work kept “Read …”. The defect predates the feature; the feature was the first thing able to expose it, and a separate ticket was not worth the overhead.
Q28 / Q29 / Q32 / Q33 — coverage caveats recorded rather than silently ticked. Four entries record tests that transcribe the implementation (triggerTotality, defaultFunctionTotality's preferred ladder, the capability-gate half of inferenceSoundness) and branches that hold by construction (the unfireable post-trim guard, Req 1.4's seeded-selection exemption, Req 3.8's mismatch arms). Each names its load-bearing counterpart. Recording the caveat beats leaving a design's testing bullet ticked on a test that cannot fail.

Review findings

SeverityAreaFindingResolution
majorqualityA URL-disclosure collapse tap while an anchoring was pending was silently deferred: the model recorded a collapse that the view ignored, so the section would snap shut the instant the reader completed the pair — and the recorded didAutoExpandForChapter was taken under the pending guard's forced chapterUnsourced == false.Replaced with a view-model-owned setURLDisclosureExpanded(_:) that is the single authority behind the binding; a collapse while pending is ignored outright, expansion always honoured (920fac5).
majorqualityThe Req 3.21 rename rule was written three times — composed apply, re-parse commit, Recalculate's change detector — and inverted in two of them, with the detector name-blind entirely.Collapsed into a single Work.refreshParsedTitle(to:commit:) helper with a commit flag, so the two write sites and the read-only detector ask literally the same question (920fac5).
majorspecReq 1.7's display half — what the anchoring menus show for a retained, defaulted or undecided side — had no test at all.Extracted as the nonisolated pure static ComposedTeachingPresentation.anchorRowState and covered by five tests (920fac5).
majordocsThe changelog described fabricated tapas title families rather than the real pair the feature was built and tested against.Replaced with the real Comics/Novels pair (c408b82).
majordocsThe changelog inverted the m.fanfiction.net re-pin claim, describing the opposite of what the branch enables.Corrected (c408b82).
minorreuseThe seeding fidelity guard compared definitions with structural ==, so a canonically-equal but byte-distinct definition would fail the guard and drop the reader to the default selection.Moved to RuleDefinitionComparator.semanticallyEqual / trimsEqual, matching the projection's own faithfulness check (920fac5).
minorreusepartBoundaries restated the chip builder's arithmetic instead of asking it, so the boundaries a seed could land on could drift from the chips the reader can actually tap.Derived from titleChips directly (920fac5).
minorqualityThe urlRuleSettled commit gate was copy-pasted across the commit paths.Moved into buildRequest(), the single construction point (920fac5).
minorqualityanchoringNotice was cleared at eight scattered gesture sites, so any handler that forgot would let a refusal outlive the gesture it described.Collapsed into a single beginGesture() called at the top of every gesture and the re-seed (920fac5).
minorqualityThe two anchor rows each re-derived their slot's resolution, running it four times per render and allowing the two sides of one slot to disagree in principle.Derived once per block and passed into both rows (920fac5).
minorqualitysetURLRuleDefinition remained a public entry point after updateURLRule superseded it, with nothing saying which new callers should use.Documented as a test seam for the six pre-status suites; production code calls updateURLRule (920fac5).
minorspecReq 3.6's two-family acceptance had no repository-level test — only the editor-side inference was covered.Added a repository test with both families (Comics + Novels) resolving to one Work with sequences 12 and 5 (920fac5).
minorspecSuffix-trim seeding and the chapterless-plus-trims seeding path were untested.Tests added (920fac5).
nitqualityAssorted cleanups: isContiguous duplicated, a describe-forwarder left after the relocation, segmentRoles inlined rather than extracted, optionalSequenceToggle re-parsing components on every read, an edited-invariant comment missing, the split-channel rationale unstated, and six test names not matching their bodies.All applied (920fac5); doc fixes — the Q31 carve-out in task 12, task 3's Blocked-by cleanup, the overview status, and the agent-note refresh — landed in c408b82.
nitqualityseededFrom appeared to conflate cleared and pending, both recording a nil definition.Not collapsed — the naive fix would have wiped a half-chosen anchoring mid-gesture, since a pending publication's nil is the editor's own echo rather than an external clear. Corrected during the review and the reasoning recorded in the comment (920fac5).
minorefficiencyeffectiveTitleRule re-runs the inference pipeline roughly ten times per tap and could be memoised.Skipped — tens of microseconds per run against a real stale-cache bug risk on the app's highest-regression surface.
nitefficiencycombinedTemplateCore re-resolves the work slot on each call.Skipped — same cost class as above, and the single-guard derivation is worth more than the microseconds.
nitreuseedgeTrimRule could reuse WholeTitleRuleDeriver.trims for its slicing.Skipped — the guards differ (per-edge derivation with a proper-subset requirement), and the win is small.
nitreuseThe edge-trim exactness guard could compare via ExactScalarString.Skipped — both sides slice the same title, so the comparison is already exact.
nitspecTest comments cite requirement numbers from other specs.Skipped — a pre-existing pattern across this surface's suites; changing it here alone would be inconsistent.

Per-file diffs

Click to expand.

Asterism/Asterism/Views/ComposedURLEditorState.swift Modified +603 / −69
diff --git a/Asterism/Asterism/Views/ComposedURLEditorState.swift b/Asterism/Asterism/Views/ComposedURLEditorState.swiftindex 50381b0..3618cef 100644--- a/Asterism/Asterism/Views/ComposedURLEditorState.swift+++ b/Asterism/Asterism/Views/ComposedURLEditorState.swift@@ -15,16 +15,148 @@ extension ComposedTeachingPresentation {     /// Which field the next chip tap fills.     public nonisolated enum URLSlot: Equatable, Sendable { case work, sequence } -    /// The definition a set of selections authors, plus the split editor's+    /// Which side of a selected path component an anchoring choice applies to.+    /// `nonisolated` for the same reason `URLComponentSelection` is: the app+    /// target defaults to main-actor isolation, and these values are compared —+    /// and hashed, into `URLLocatorResolution.pending` — from nonisolated code.+    public nonisolated enum AnchorSide: Equatable, Hashable, Sendable, CaseIterable {+        case left, right+    }++    /// One slot's anchoring: the in-force side values plus the stored locator+    /// the slot retains while it cannot be shown in full (Decision 6).+    public nonisolated struct SlotAnchoring: Equatable, Sendable {+        /// In force = reader-chosen, or reflected from a stored locator at seed+        /// time; the two are stored identically, so Req 1.9's survival rule+        /// covers both. `nil` = no preference, and the default function decides+        /// at derivation time. **A default is never written here** — that is+        /// what makes "defaults recompute, choices survive" hold.+        public var left: PathAnchor?+        public var right: PathAnchor?+        /// The stored locator, retained verbatim while the controls cannot show+        /// it — either its anchors do not bracket the displayed chip (Req 1.7's+        /// hidden case) or it seeds no chip at all (it resolves nowhere on this+        /// URL). It occupies the slot in `rule(in:)`'s form selection, so a+        /// hidden Work locator cannot let `.workAndSequence` narrow to+        /// `.sequence` — the defect class that moved 40 captures off their+        /// version-2 keys. Retention covers every locator kind, query locators+        /// included (Q25).+        public var retainedLocator: URLComponentLocator?+        /// Whether the reader has edited a side since seeding — the trigger that+        /// moves a retained-locator slot from "stored rule in effect" to+        /// "replacement in progress" (Req 1.8).+        ///+        /// Under a retained locator `edited == (left != nil || right != nil)` by+        /// construction — seeding a retained locator leaves both sides nil,+        /// `chooseAnchor` sets a side and this flag in the same transition, and+        /// completing the pair drops the locator. It is a separate field anyway+        /// because it also has to hold for the slots that carry no retained+        /// locator, where "no side chosen" and "not edited" are not the same+        /// thing.+        public var edited: Bool++        public init(+            left: PathAnchor? = nil, right: PathAnchor? = nil,+            retainedLocator: URLComponentLocator? = nil, edited: Bool = false+        ) {+            self.left = left+            self.right = right+            self.retainedLocator = retainedLocator+            self.edited = edited+        }++        /// The sides with no value in force — what a pending status names.+        var missingSides: Set<AnchorSide> {+            var missing: Set<AnchorSide> = []+            if left == nil { missing.insert(.left) }+            if right == nil { missing.insert(.right) }+            return missing+        }+    }++    /// Why a transition was refused, with the reader-facing wording that names+    /// the ground (Reqs 1.3, 1.4). Carried back rather than thrown: a refusal is+    /// an answer to a gesture, not an error condition.+    public nonisolated struct TransitionRefusal: Equatable, Sendable {+        public enum Ground: Equatable, Sendable {+            /// The candidate does not resolve exactly once, at the selected+            /// component, on the URL it is being taught from.+            case doesNotResolve+            /// The representation rejects the locator. The both-sides-unanchored+            /// combination is always this one — it resolves fine on a+            /// single-component path, and is refused anyway (Q8).+            case representationRejected+            /// No offered anchoring at all is valid for the component (Req 1.4).+            case noAuthorableAnchoring+        }++        public let ground: Ground+        public let message: String++        init(ground: Ground, message: String) {+            self.ground = ground+            self.message = message+        }+    }++    /// What one slot authors. Decision 5's four outcomes collapse to three here:+    /// the default and an in-force pair both produce `.locator`.+    public nonisolated enum URLLocatorResolution: Equatable, Sendable {+        case locator(URLComponentLocator)+        /// Several anchorings qualify and they mean different things, so the+        /// reader decides (Req 1.2 outcome 3 / Req 1.8).+        case pending(missing: Set<AnchorSide>)+        /// No anchoring the representation admits singles this component out+        /// (Req 1.4), or the component is blank.+        case unauthorable+    }++    /// What the current selections author (Q22).+    ///+    /// An optional definition was the only output channel until this feature,+    /// and nil is indistinguishable downstream from a deliberate clear — which+    /// `chapterUnsourced`, the collapsed summary and request construction all+    /// read as "the reader cleared it". Pending could therefore never gate+    /// commit with an explanation, so the status is typed.+    public nonisolated enum URLRuleStatus: Equatable, Sendable {+        case cleared+        case valid(URLRuleDefinition)+        /// An anchoring pair is half-chosen; the message names the incomplete+        /// side (Req 1.8).+        case pending(message: String)+        /// A selected component no anchoring can single out (Req 1.4).+        case unauthorable(message: String)++        /// The definition this status carries, or nil — which the other three+        /// cases deliberately share while staying distinct from one another.+        public var definition: URLRuleDefinition? {+            guard case .valid(let definition) = self else { return nil }+            return definition+        }+    }++    /// The status a set of selections authors, plus the split editor's     /// reader-facing failure when a within-component split cannot be derived.+    ///+    /// The two travel together because a split failure is not a status of its+    /// own (task 5.2's shape): the selections author no rule, so the status is+    /// `.cleared` — which the commit gates read as settled, exactly as they read+    /// a reader's explicit clear — and the reason the split could not be derived+    /// rides beside it on its own channel, shown by the split editor and nowhere+    /// else. Making it a fourth status would block the commit on a message about+    /// an editor the reader may have already left.     public nonisolated struct URLRuleOutcome: Equatable, Sendable {-        public let definition: URLRuleDefinition?+        public let status: URLRuleStatus         public let splitErrorMessage: String? -        init(definition: URLRuleDefinition?, splitErrorMessage: String? = nil) {-            self.definition = definition+        init(status: URLRuleStatus, splitErrorMessage: String? = nil) {+            self.status = status             self.splitErrorMessage = splitErrorMessage         }++        /// The definition the status carries, or nil for every other status —+        /// the shape the editor's dispatch and the existing suites read.+        public var definition: URLRuleDefinition? { status.definition }     }      /// The URL details editor's selection state and the pure transitions the@@ -59,9 +191,79 @@ extension ComposedTeachingPresentation {         /// declared optional" (Req 2.3), so an untouched toggle authors exactly         /// the rule a build without this feature would (Req 2.2).         public private(set) var sequencePresence: URLSequencePresence = .required+        /// Per-slot anchoring: the sides in force plus the stored locator the+        /// slot retains while it cannot be shown (Decision 6). A default is+        /// never written into either, which is what makes Req 1.9's "defaults+        /// recompute, choices survive" hold.+        public private(set) var workAnchoring = SlotAnchoring()+        public private(set) var sequenceAnchoring = SlotAnchoring()          public init() {} +        // MARK: - Slot accessors++        public func selection(for slot: URLSlot) -> URLComponentSelection? {+            switch slot {+            case .work: work+            case .sequence: sequence+            }+        }++        public func anchoring(for slot: URLSlot) -> SlotAnchoring {+            switch slot {+            case .work: workAnchoring+            case .sequence: sequenceAnchoring+            }+        }++        mutating func setAnchoring(_ anchoring: SlotAnchoring, for slot: URLSlot) {+            switch slot {+            case .work: workAnchoring = anchoring+            case .sequence: sequenceAnchoring = anchoring+            }+        }++        /// What a slot authors, given everything it holds. Nil where the slot is+        /// occupied by neither a selection nor a stored locator.+        ///+        /// This is the design's slot-resolution table. It is total because+        /// "retained locator ∧ both sides in force" is unreachable:+        /// `chooseAnchor` drops the retained locator inside the same transition+        /// that completes the pair.+        static func resolution(+            selection: URLComponentSelection?, slot: URLSlot, anchoring: SlotAnchoring,+            in components: RawURLLexicalComponents+        ) -> URLLocatorResolution? {+            guard let selection else {+                // Occupied only by a stored locator that seeds no chip at all —+                // it resolves nowhere on this URL. Retaining it is what keeps a+                // stored `.workAndSequence` from narrowing to `.sequence` (Q25).+                return anchoring.retainedLocator.map { .locator($0) }+            }+            if let retained = anchoring.retainedLocator {+                // The stored rule stays in effect until the reader edits this+                // slot's anchoring, so orthogonal gestures republish it+                // byte-identically (Decision 6).+                guard anchoring.edited else { return .locator(retained) }+                // Replacement in progress: no auto-completion may displace an+                // anchor the controls never showed (Req 1.8).+                guard let left = anchoring.left, let right = anchoring.right else {+                    return .pending(missing: anchoring.missingSides)+                }+                return .locator(.pathBracketed(left: left, right: right))+            }+            return ComposedTeachingPresentation.urlLocatorResolution(+                for: selection, slot: slot, anchoring: anchoring, in: components)+        }++        public func resolution(+            for slot: URLSlot, in components: RawURLLexicalComponents+        ) -> URLLocatorResolution? {+            Self.resolution(+                selection: selection(for: slot), slot: slot, anchoring: anchoring(for: slot),+                in: components)+        }+         // MARK: - Transitions          /// Mirror an externally-held definition into the local selections. Runs on@@ -73,6 +275,10 @@ extension ComposedTeachingPresentation {             // stored `.combined` template below and reset everywhere else, so a             // stale declaration never outlives the template it was made on.             sequencePresence = .required+            // A different capture resets the anchoring, edited flag included, so+            // a choice never outlives the URL it was made on (Req 1.9).+            workAnchoring = SlotAnchoring()+            sequenceAnchoring = SlotAnchoring()             guard let definition, let components else {                 work = nil                 sequence = nil@@ -82,19 +288,19 @@ extension ComposedTeachingPresentation {             }             switch definition {             case .work(let locator):-                work = Self.selection(for: locator, in: components)+                seedSlot(.work, from: locator, in: components)                 sequence = nil                 split = nil                 retainedTemplate = nil             case .sequence(let locator):                 work = nil-                sequence = Self.selection(for: locator, in: components)+                seedSlot(.sequence, from: locator, in: components)                 split = nil                 retainedTemplate = nil                 activeSlot = .sequence             case .workAndSequence(let workSelector, let sequenceSelector):-                work = Self.selection(for: workSelector.locator, in: components)-                sequence = Self.selection(for: sequenceSelector.locator, in: components)+                seedSlot(.work, from: workSelector.locator, in: components)+                seedSlot(.sequence, from: sequenceSelector.locator, in: components)                 split = nil                 retainedTemplate = nil             case .combined(let locator, let template):@@ -103,7 +309,7 @@ extension ComposedTeachingPresentation {                 // itself is retained, so re-anchoring the same component keeps                 // it rather than silently narrowing the rule to `.work` — which                 // is what moved 40 captures off their version-2 keys (Req 3.7).-                work = Self.selection(for: locator, in: components)+                seedSlot(.work, from: locator, in: components)                 sequence = nil                 split = nil                 retainedTemplate = template@@ -113,10 +319,57 @@ extension ComposedTeachingPresentation {             }         } -        /// A chip tap into the active slot.+        /// One slot's share of `seed`: the chip the stored locator lands on, plus+        /// the anchoring the controls may show for it.+        ///+        /// Req 1.7 has two branches and this is the fork. The stored pair is+        /// **reflected** only where it brackets exactly the chip the editor+        /// displays; anywhere else — the anchors resolve at another index (the+        /// out-of-scope Q10 seeding defect), the locator resolves nowhere at+        /// all, or it is a shape the anchoring controls do not speak (an+        /// imported V2 offset) — the locator itself is retained, so the stored+        /// rule stays in effect and nothing depicts a different anchoring as if+        /// it were the stored one (Decision 6).+        private mutating func seedSlot(+            _ slot: URLSlot, from locator: URLComponentLocator,+            in components: RawURLLexicalComponents+        ) {+            let selection = Self.selection(for: locator, in: components)+            switch slot {+            case .work: work = selection+            case .sequence: sequence = selection+            }+            var anchoring = SlotAnchoring()+            switch (selection, locator) {+            case (.path(let index)?, .pathBracketed(let left, let right))+            where ComposedTeachingPresentation.bracketedIndices(+                left: left, right: right, in: components) == [index]:+                anchoring.left = left+                anchoring.right = right+            case (.query(let index)?, .query(let name))+            where components.queryItems.indices.contains(index)+                && components.queryItems[index].name == name:+                // A query chip carries its own name; there is no anchoring to+                // show and nothing hidden, so nothing is retained.+                break+            default:+                anchoring.retainedLocator = locator+            }+            setAnchoring(anchoring, for: slot)+        }++        /// A chip tap into the active slot, applied unconditionally — the seeded+        /// and internal path. Reader taps route through `selectComponent`, which+        /// probes first (Req 1.4).         public mutating func select(_ selection: URLComponentSelection) {             split = nil-            switch activeSlot {+            let slot = activeSlot+            // Anchoring is a property of the selected component: a same-index+            // re-tap keeps it (Req 1.9, Q6), a different component replaces the+            // locator and resets it, and so does nil→component, which is a+            // replacement rather than a side edit.+            if self.selection(for: slot) != selection { setAnchoring(SlotAnchoring(), for: slot) }+            switch slot {             case .work:                 // Retained only when the *same* component is re-selected, and                 // compared by index rather than by text, since a path can repeat@@ -131,6 +384,102 @@ extension ComposedTeachingPresentation {             }         } +        /// A reader's chip tap into the active slot (Req 1.4).+        ///+        /// Refused when no anchoring offered for the component is valid — the+        /// `/a//x//b` case, where the representation has nothing to say about+        /// `x` at all. The probe runs against the state the tap *would* produce,+        /// so a refusal leaves everything alone: `select` clears the split and,+        /// off the same-component path, the retained template.+        ///+        /// A selection seeded from a stored rule bypasses this — `seed` calls+        /// `select` directly, because Reqs 1.6 and 1.7 govern it instead (Q19).+        public mutating func selectComponent(+            _ selection: URLComponentSelection, in components: RawURLLexicalComponents+        ) -> TransitionRefusal? {+            let slot = activeSlot+            let prospective =+                self.selection(for: slot) == selection ? anchoring(for: slot) : SlotAnchoring()+            if case .unauthorable? = Self.resolution(+                selection: selection, slot: slot, anchoring: prospective, in: components)+            {+                return TransitionRefusal(+                    ground: .noAuthorableAnchoring,+                    message: ComposedTeachingPresentation.componentUnauthorableNotice)+            }+            select(selection)+            return nil+        }++        /// The reader pins one side of a slot's selected component (Reqs 1.1,+        /// 1.3, 1.11).+        ///+        /// Refused unless at least one valid pair contains the anchor, with the+        /// other side fixed to its in-force value where one exists and free+        /// otherwise — so a first choice is judged against every completion of+        /// the other side, and a second against the one already standing.+        public mutating func chooseAnchor(+            slot: URLSlot, side: AnchorSide, anchor: PathAnchor,+            in components: RawURLLexicalComponents+        ) -> TransitionRefusal? {+            var anchoring = self.anchoring(for: slot)+            // Only a path component has sides to anchor; a query item carries its+            // own name and the controls offer it nothing to choose. Refused with+            // that reason rather than with the selection-refusal wording, which+            // would tell the reader to pick a different part of the URL.+            guard case .path(let index)? = selection(for: slot) else {+                return TransitionRefusal(+                    ground: .noAuthorableAnchoring,+                    message: ComposedTeachingPresentation.anchoringNotApplicableNotice)+            }+            let otherSide: AnchorSide = side == .left ? .right : .left+            let inForce = otherSide == .left ? anchoring.left : anchoring.right+            let others = inForce.map { [$0] }+                ?? ComposedTeachingPresentation.offeredAnchors(+                    side: otherSide, at: index, in: components)++            // The two grounds are told apart by which check the candidates fail:+            // where none is even representable the refusal is the representation's+            // (the both-unanchored pair always lands here, resolving or not).+            var sawRepresentable = false+            var accepted = false+            for other in others {+                let left = side == .left ? anchor : other+                let right = side == .left ? other : anchor+                guard ComposedTeachingPresentation.isRepresentable(left: left, right: right)+                else { continue }+                sawRepresentable = true+                if ComposedTeachingPresentation.resolvesUniquely(+                    left: left, right: right, at: index, in: components)+                {+                    accepted = true+                    break+                }+            }+            guard accepted else {+                return sawRepresentable+                    ? TransitionRefusal(+                        ground: .doesNotResolve,+                        message: ComposedTeachingPresentation.anchoringDoesNotResolveNotice)+                    : TransitionRefusal(+                        ground: .representationRejected,+                        message: ComposedTeachingPresentation.anchoringUnrepresentableNotice)+            }++            switch side {+            case .left: anchoring.left = anchor+            case .right: anchoring.right = anchor+            }+            anchoring.edited = true+            // The authored pair replaces the stored locator the moment it is+            // complete. Dropping it here — inside the transition that completes+            // the pair — is what makes "retained locator ∧ both sides in force"+            // unreachable, and the slot-resolution table total.+            if anchoring.left != nil, anchoring.right != nil { anchoring.retainedLocator = nil }+            setAnchoring(anchoring, for: slot)+            return nil+        }+         /// The declaration is a property of the template it was made on, so the         /// two are dropped together (design "Teaching UI": reset semantics follow         /// the retained template's exactly).@@ -188,36 +537,63 @@ extension ComposedTeachingPresentation {             sequence = nil             split = nil             clearRetainedTemplate()+            workAnchoring = SlotAnchoring()+            sequenceAnchoring = SlotAnchoring()         }          // MARK: - The authored definition -        /// Builds the URL rule definition the current selections author: a-        /// Work-identity rule, a sequence-only rule, an identity+sequence pair, or-        /// a within-component combined template.+        /// Builds the URL rule the current selections author: a Work-identity+        /// rule, a sequence-only rule, an identity+sequence pair, or a+        /// within-component combined template — or the status that says why+        /// none of them is authored yet.+        ///+        /// Every slot goes through `resolution(for:in:)`, so a retained stored+        /// locator occupies its slot exactly as a selection does and no branch+        /// can silently narrow a stored rule's form.         public mutating func rule(in components: RawURLLexicalComponents?) -> URLRuleOutcome {-            guard let components else { return URLRuleOutcome(definition: nil) }+            guard let components else { return URLRuleOutcome(status: .cleared) }+            let workResolution = resolution(for: .work, in: components)+            let sequenceResolution = resolution(for: .sequence, in: components)++            // Pending and unauthorable are statuses, not definitions. Both+            // return before the `.combined` branches, so neither performs the+            // `retainedTemplate`/`sequencePresence` write-back — those writes+            // stay confined to the branches that emit a definition.+            for (slot, resolution) in [+                (URLSlot.work, workResolution), (.sequence, sequenceResolution),+            ] {+                switch resolution {+                case .pending(let missing):+                    return URLRuleOutcome(+                        status: .pending(+                            message: ComposedTeachingPresentation.anchoringPendingNotice(+                                slot: slot, missing: missing)))+                case .unauthorable:+                    return URLRuleOutcome(+                        status: .unauthorable(+                            message: ComposedTeachingPresentation.componentUnauthorableNotice))+                case .locator, .none:+                    continue+                }+            } -            // Sequence-only rule: the sequence slot is filled and the Work slot+            // Sequence-only rule: the sequence slot is occupied and the Work slot             // empty (the whole title names the Work; the URL supplies the chapter).-            if work == nil, let sequenceSelection = sequence {-                guard let locator = ComposedTeachingPresentation.urlLocator(-                    for: sequenceSelection, in: components)-                else { return URLRuleOutcome(definition: nil) }-                return URLRuleOutcome(definition: .sequence(locator: locator))+            if workResolution == nil, case .locator(let sequenceLocator)? = sequenceResolution {+                return URLRuleOutcome(status: .valid(.sequence(locator: sequenceLocator)))             } -            guard let workSelection = work,-                  let workLocator = ComposedTeachingPresentation.urlLocator(-                    for: workSelection, in: components)-            else { return URLRuleOutcome(definition: nil) }+            guard case .locator(let workLocator)? = workResolution else {+                return URLRuleOutcome(status: .cleared)+            }              // A live split supersedes anything retained, and becomes what is             // retained if the reader then re-anchors the same component.             if split != nil {                 do {                     guard let template = try combinedTemplateCore(in: components) else {-                        return URLRuleOutcome(definition: nil)+                        return URLRuleOutcome(status: .cleared)                     }                     retainedTemplate = template                     // Req 1.10: the core drops a declaration the newly derived@@ -227,27 +603,28 @@ extension ComposedTeachingPresentation {                     // `.required`, and the gate and dispatch disagree.                     sequencePresence = template.sequencePresence                     return URLRuleOutcome(-                        definition: .combined(locator: workLocator, template: template))+                        status: .valid(.combined(locator: workLocator, template: template)))                 } catch let error as URLTemplateSelectionError {                     return URLRuleOutcome(-                        definition: nil,+                        status: .cleared,                         splitErrorMessage: ComposedTeachingPresentation.splitGuidance(for: error))                 } catch {                     return URLRuleOutcome(-                        definition: nil, splitErrorMessage: String(describing: error))+                        status: .cleared, splitErrorMessage: String(describing: error))                 }             } -            // Identity + sequence across two components.-            if let sequenceSelection = sequence {-                guard sequenceSelection != workSelection,-                      let sequenceLocator = ComposedTeachingPresentation.urlLocator(-                        for: sequenceSelection, in: components),-                      sequenceLocator != workLocator-                else { return URLRuleOutcome(definition: nil) }-                return URLRuleOutcome(definition: .workAndSequence(+            // Identity + sequence across two components. The clash — one+            // component in both slots, or two slots resolving one locator —+            // keeps publishing a cleared status with the existing hint row,+            // exactly as today; upgrading it to unauthorable would block a+            // commit today permits (Q26).+            if case .locator(let sequenceLocator)? = sequenceResolution {+                guard sequence == nil || sequence != work, sequenceLocator != workLocator+                else { return URLRuleOutcome(status: .cleared) }+                return URLRuleOutcome(status: .valid(.workAndSequence(                     work: URLFieldSelector(locator: workLocator),-                    sequence: URLFieldSelector(locator: sequenceLocator)))+                    sequence: URLFieldSelector(locator: sequenceLocator))))             }              // The stored template survives the re-anchor (Req 3.7). Without this@@ -265,10 +642,10 @@ extension ComposedTeachingPresentation {                 retainedTemplate = template                 sequencePresence = template.sequencePresence                 return URLRuleOutcome(-                    definition: .combined(locator: workLocator, template: template))+                    status: .valid(.combined(locator: workLocator, template: template)))             } -            return URLRuleOutcome(definition: .work(locator: workLocator))+            return URLRuleOutcome(status: .valid(.work(locator: workLocator)))         }          // MARK: - The declaration's gate (Req 1.10, Decision 5)@@ -284,10 +661,12 @@ extension ComposedTeachingPresentation {         /// `dispatchRuleDefinition` does not call `validate`, and the disabled         /// control is what keeps the reader from meeting         /// `invalidURLDefinition` at projection time instead.+        ///+        /// A non-`.locator` slot resolution closes the gate: a pending or+        /// unauthorable Work slot authors no rule to declare anything about.         public func canDeclareSequenceOptional(in components: RawURLLexicalComponents?) -> Bool {-            guard let components, let workSelection = work,-                  let locator = ComposedTeachingPresentation.urlLocator(-                    for: workSelection, in: components),+            guard let components,+                  case .locator(let locator)? = resolution(for: .work, in: components),                   let template = combinedTemplate(in: components)             else { return false }             let declared = URLRuleDefinition.combined(@@ -319,10 +698,8 @@ extension ComposedTeachingPresentation {         private func combinedTemplateCore(             in components: RawURLLexicalComponents         ) throws -> URLTwoFieldTemplate? {-            guard let workSelection = work,-                  ComposedTeachingPresentation.urlLocator(for: workSelection, in: components) != nil-            else { return nil }-            if let split {+            guard case .locator? = resolution(for: .work, in: components) else { return nil }+            if let split, let workSelection = work {                 guard let text = Self.componentText(for: workSelection, in: components) else {                     return nil                 }@@ -335,8 +712,10 @@ extension ComposedTeachingPresentation {                     from: ExactScalarString(text), selection: split, presence: sequencePresence)                 return derived.declaring(Self.presence(sequencePresence, boundedBy: derived))             }-            // A separate sequence component authors a two-locator rule instead.-            guard sequence == nil, let retainedTemplate else { return nil }+            // A separate sequence component — or a stored sequence locator the+            // slot retains — authors a two-locator rule instead.+            guard resolution(for: .sequence, in: components) == nil, let retainedTemplate+            else { return nil }             return retainedTemplate.declaring(Self.presence(sequencePresence, boundedBy: retainedTemplate))         } @@ -392,6 +771,54 @@ extension ComposedTeachingPresentation {         }     } +    // MARK: - Index-precise resolution (Q20)++    /// The path indices the pair `(left, right)` brackets, **blind to+    /// blankness** — core rejects a blank component only after the uniqueness+    /// check, and this mirrors that exactly.+    ///+    /// Core's `select` returns the matched *value*, not its index, so a locator+    /// can resolve uniquely at the wrong index of a path that repeats a value.+    /// Req 1.3 needs the index ("resolves at the selected component"), and an+    /// additive core query API was rejected to keep the AsterismCore diff empty+    /// (Q20), so `URLRuleApplicator`'s predicate is restated here and pinned to+    /// it by a differential test.+    ///+    /// "Resolves at exactly the selected index" is `bracketedIndices == [index]`+    /// **and** a non-blank component there.+    public nonisolated static func bracketedIndices(+        left: PathAnchor, right: PathAnchor, in components: RawURLLexicalComponents+    ) -> [Int] {+        let path = components.pathComponents+        return path.indices.filter { index in+            matches(left: left, at: index, in: path) && matches(right: right, at: index, in: path)+        }+    }++    private nonisolated static func matches(+        left anchor: PathAnchor, at index: Int, in path: [ExactScalarString]+    ) -> Bool {+        switch anchor {+        case .start: index == path.startIndex+        case .literal(let literal): index > path.startIndex && path[index - 1] == literal+        // A left side can never be the path's end, as core's `matches` has it.+        case .end: false+        case .unanchored: true+        }+    }++    private nonisolated static func matches(+        right anchor: PathAnchor, at index: Int, in path: [ExactScalarString]+    ) -> Bool {+        switch anchor {+        case .end: index == path.index(before: path.endIndex)+        case .literal(let literal): index + 1 < path.endIndex && path[index + 1] == literal+        // Mirrored: a right side can never be the path's start.+        case .start: false+        case .unanchored: true+        }+    }+     /// The locator a chip tap authors.     ///     /// Decision 3: the right side defaults to **unanchored**, because every@@ -400,33 +827,140 @@ extension ComposedTeachingPresentation {     /// it. `.end` is preferred where the selected component is genuinely last,     /// being strictly more precise and costing nothing.     ///-    /// The left side keeps its existing scan, blank-skipping defect included-    /// (Q10): every left-hand neighbour in the library is a stable path segment.+    /// Kept as the one-argument convenience the pure builder tests and the+    /// template helpers use: the Work slot, with no anchoring in force, taking+    /// only what the default function authors outright.     public nonisolated static func urlLocator(         for selection: URLComponentSelection, in components: RawURLLexicalComponents     ) -> URLComponentLocator? {+        guard case .locator(let locator) = urlLocatorResolution(+            for: selection, slot: .work, anchoring: SlotAnchoring(), in: components)+        else { return nil }+        return locator+    }++    // MARK: - The default function (Req 1.2, Decision 5)++    /// What a selected component authors for a slot, honouring any side already+    /// in force.+    ///+    /// The order is Req 1.2's: the preferred derivation where it is valid, else+    /// the sole valid candidate, else undecided, else unauthorable. "Valid"+    /// means both of Req 1.3's grounds pass — the representation admits the pair+    /// (core's own `validate`, the `canDeclareSequenceOptional` precedent) and+    /// it brackets exactly the selected index with a non-blank component there.+    ///+    /// A retained stored locator is *not* consulted here; slot resolution+    /// (`URLEditorState.resolution(for:in:)`) handles that row of the table+    /// before reaching this function.+    public nonisolated static func urlLocatorResolution(+        for selection: URLComponentSelection, slot: URLSlot, anchoring: SlotAnchoring,+        in components: RawURLLexicalComponents+    ) -> URLLocatorResolution {         switch selection {+        case .query(let index):+            // The bracketed-anchor shape is specific to path locators, so a+            // query item authors its name and nothing is offered to choose.+            guard components.queryItems.indices.contains(index) else { return .unauthorable }+            let name = components.queryItems[index].name+            guard !name.isBlank else { return .unauthorable }+            return .locator(.query(name: name))         case .path(let index):-            let pathComponents = components.pathComponents-            guard pathComponents.indices.contains(index), !pathComponents[index].isBlank else {-                return nil-            }-            var left = PathAnchor.start-            var leftIndex = index - 1-            while leftIndex >= 0 {-                if !pathComponents[leftIndex].isBlank {-                    left = .literal(pathComponents[leftIndex])-                    break+            let path = components.pathComponents+            guard path.indices.contains(index), !path[index].isBlank else { return .unauthorable }+            let lefts = anchoring.left.map { [$0] }+                ?? offeredAnchors(side: .left, at: index, in: components)+            let rights = anchoring.right.map { [$0] }+                ?? offeredAnchors(side: .right, at: index, in: components)+            var candidates: [URLComponentLocator] = []+            for left in lefts {+                for right in rights+                where isValidAnchoring(left: left, right: right, at: index, in: components) {+                    candidates.append(.pathBracketed(left: left, right: right))                 }-                leftIndex -= 1             }-            let right: PathAnchor = index == pathComponents.count - 1 ? .end : .unanchored-            return .pathBracketed(left: left, right: right)-        case .query(let index):-            guard components.queryItems.indices.contains(index) else { return nil }-            let name = components.queryItems[index].name-            guard !name.isBlank else { return nil }-            return .query(name: name)+            guard !candidates.isEmpty else { return .unauthorable }+            let preferred = preferredAnchoring(at: index, slot: slot, in: components)+            if candidates.contains(preferred) { return .locator(preferred) }+            if candidates.count == 1 { return .locator(candidates[0]) }+            // More than one qualifies and they mean different things — which+            // neighbours are stable and which are story-specific is the reader's+            // call, not the editor's (Non-Goals).+            return .pending(missing: anchoring.missingSides)         }     }++    /// Every anchoring applicable on one side of a selected component (Req 1.1):+    /// the immediately neighbouring component's literal where that neighbour+    /// exists and is not blank, the path boundary where the component is first+    /// or last, and unanchored.+    public nonisolated static func offeredAnchors(+        side: AnchorSide, at index: Int, in components: RawURLLexicalComponents+    ) -> [PathAnchor] {+        let path = components.pathComponents+        guard path.indices.contains(index) else { return [] }+        var offered: [PathAnchor] = []+        switch side {+        case .left:+            if index == path.startIndex { offered.append(.start) }+            if index > path.startIndex, !path[index - 1].isBlank {+                offered.append(.literal(path[index - 1]))+            }+        case .right:+            if index + 1 < path.endIndex, !path[index + 1].isBlank {+                offered.append(.literal(path[index + 1]))+            }+            if index == path.index(before: path.endIndex) { offered.append(.end) }+        }+        offered.append(.unanchored)+        return offered+    }++    /// The derivation the default function prefers among the valid candidates.+    ///+    /// Decision 4: the left side considers only the **immediate** neighbour. The+    /// old scan skipped blanks while the applicator checks the neighbour, so on+    /// `/a//b` it authored `.literal("a")` — a locator that fails against the+    /// very URL it was taught from.+    nonisolated static func preferredAnchoring(+        at index: Int, slot: URLSlot, in components: RawURLLexicalComponents+    ) -> URLComponentLocator {+        let path = components.pathComponents+        let isLast = index == path.count - 1+        // Req 2.1: a chapter taken whole from the path's last component holds+        // across the site's works — its left neighbour is the story's own slug.+        // Only the sequence slot; a chapter taken from a split of a component+        // shares that component's locator with Work identity (Q7).+        if slot == .sequence, isLast { return .pathBracketed(left: .unanchored, right: .end) }+        let left: PathAnchor =+            index > 0 && !path[index - 1].isBlank ? .literal(path[index - 1]) : .start+        return .pathBracketed(left: left, right: isLast ? .end : .unanchored)+    }++    /// Req 1.3's two grounds, both of which a candidate must clear.+    nonisolated static func isValidAnchoring(+        left: PathAnchor, right: PathAnchor, at index: Int, in components: RawURLLexicalComponents+    ) -> Bool {+        isRepresentable(left: left, right: right)+            && resolvesUniquely(left: left, right: right, at: index, in: components)+    }++    /// Whether the representation admits the pair at all — asked of the core's+    /// own `validate`, so the editor cannot offer what commit refuses. The+    /// both-sides-unanchored combination is always refused here.+    nonisolated static func isRepresentable(left: PathAnchor, right: PathAnchor) -> Bool {+        (try? URLRuleDefinition.work(locator: .pathBracketed(left: left, right: right))+            .validate(origin: .readerTaught, isCurrent: true)) != nil+    }++    /// Whether the pair resolves exactly once, at the selected component, on the+    /// URL it is being taught from.+    nonisolated static func resolvesUniquely(+        left: PathAnchor, right: PathAnchor, at index: Int, in components: RawURLLexicalComponents+    ) -> Bool {+        guard components.pathComponents.indices.contains(index),+              !components.pathComponents[index].isBlank+        else { return false }+        return bracketedIndices(left: left, right: right, in: components) == [index]+    } }
Asterism/Asterism/Views/ComposedTeachingPresentation.swift Modified +423 / −24
diff --git a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift b/Asterism/Asterism/Views/ComposedTeachingPresentation.swiftindex 54f1e00..ff98579 100644--- a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift+++ b/Asterism/Asterism/Views/ComposedTeachingPresentation.swift@@ -23,8 +23,191 @@ public enum ComposedTeachingPresentation {     public static let lastWorkNotice =         "Every site needs a Work name. Mark another part as the Work before changing this one." -    public static let workSlotLabel = "Work identity"-    public static let sequenceSlotLabel = "Chapter sequence"+    /// Shown when a stored title rule stays in effect but its selection cannot+    /// be reproduced on this capture's title — the trims do not match it, or a+    /// kept boundary falls where no chip does (Req 3.8). The title side's mirror+    /// of the URL side's stored-anchoring notice: the editor holds its default+    /// selection rather than depicting a different rule as if it were the+    /// stored one.+    public static let storedTitleRuleNotice =+        "The stored title rule is still in effect; its selection can't be shown on this title. Changing the selection below replaces it."++    /// `nonisolated`: reached from the nonisolated `URLEditorState` through+    /// `anchoringPendingNotice`, and constant.+    public nonisolated static let workSlotLabel = "Work identity"+    /// `nonisolated` for the same reason as `workSlotLabel`.+    public nonisolated static let sequenceSlotLabel = "Chapter sequence"++    /// `nonisolated`: reached from the nonisolated `URLEditorState` through+    /// `anchoringPendingNotice`, and pure.+    public nonisolated static func slotLabel(for slot: URLSlot) -> String {+        switch slot {+        case .work: workSlotLabel+        case .sequence: sequenceSlotLabel+        }+    }++    // Anchoring notices (Reqs 1.3, 1.4, 1.7, 1.8). Each names its ground, in the+    // register of `lastWorkNotice`: what happened, and what to do about it.++    /// Req 1.3's first ground: the pair is a locator the representation admits,+    /// it just does not say which component of *this* URL is meant.+    /// `nonisolated`: read by the nonisolated `URLEditorState`, and constant.+    public nonisolated static let anchoringDoesNotResolveNotice =+        "That choice doesn't pick out this part of the URL on its own. Choose a different one."++    /// Req 1.3's second ground. Always the both-sides-unanchored combination,+    /// which the representation refuses even where it would resolve (Q8).+    /// `nonisolated` for the same reason as `anchoringDoesNotResolveNotice`.+    public nonisolated static let anchoringUnrepresentableNotice =+        "A URL rule has to be anchored on at least one side. Pin one side to its neighbour or to the path's edge."++    /// Req 1.4: no anchoring the representation admits singles this component+    /// out on this URL — both its neighbours are empty, or it repeats.+    /// `nonisolated` for the same reason as `anchoringDoesNotResolveNotice`.+    public nonisolated static let componentUnauthorableNotice =+        "No anchoring can pick out this part of the URL. Choose a different part."++    /// Req 1.7: a stored anchoring the controls cannot show, so nothing is+    /// depicted as the stored one that is not.+    public static let storedAnchoringNotShownNotice =+        "The stored rule is still in effect. Its anchoring can't be shown on this URL — choosing one replaces it."++    /// A query item names itself, so there is nothing to anchor on either side of+    /// it (Non-Goals). Reachable only by asking for an anchoring on a query+    /// selection, which the controls never offer.+    /// `nonisolated` for the same reason as `anchoringDoesNotResolveNotice`.+    public nonisolated static let anchoringNotApplicableNotice =+        "This part of the URL has a name of its own, so there is nothing to anchor it against."++    /// Req 1.8's commit refusal, naming the incomplete side. `nonisolated`:+    /// called from the nonisolated `URLEditorState`, and pure.+    public nonisolated static func anchoringPendingNotice(+        slot: URLSlot, missing: Set<AnchorSide>+    ) -> String {+        let phrase: String+        switch (missing.contains(.left), missing.contains(.right)) {+        case (true, true): phrase = "before it and after it"+        case (true, false): phrase = "before it"+        case (false, true): phrase = "after it"+        // No side is missing, so nothing can be named — unreachable from+        // `rule(in:)`, and answered without inventing a side rather than+        // defaulting to one of them.+        case (false, false):+            return "\(slotLabel(for: slot)): choose the anchoring in the URL before saving."+        }+        return "\(slotLabel(for: slot)): choose what comes \(phrase) in the URL before saving."+    }++    // MARK: - The anchoring control (Req 1.1, Q21)++    /// The row labels of the two per-side menus. Deliberately the plain reading+    /// of the path: the component's neighbours, not the model's anchors.+    public static func anchorSideLabel(_ side: AnchorSide) -> String {+        switch side {+        case .left: "Before it"+        case .right: "After it"+        }+    }++    /// The menu's value where no anchoring is in force and none is defaulted —+    /// the undecided state of Req 1.2's third outcome, and every side of a slot+    /// whose stored locator the controls cannot show (Req 1.7).+    public static let anchorUndecidedLabel = "Choose…"++    /// What one side's anchoring menu shows: the anchor on its face, and whether+    /// that anchor is the default in force rather than a decision the reader+    /// made. `nil` is "Choose…".+    public nonisolated struct AnchorRowState: Equatable, Sendable {+        public let anchor: PathAnchor?+        public let isDefault: Bool++        /// Nothing decided and nothing defaulted — Req 1.2's third outcome, and+        /// every side of a slot whose stored locator the controls cannot show.+        static let undecided = AnchorRowState(anchor: nil, isDefault: false)+    }++    /// Req 1.7's display half, as a value rather than as view code: the reader's+    /// own choice first, then the default the slot's resolution carries, then+    /// undecided.+    ///+    /// A retained locator the reader has not edited shows **neither** side: the+    /// stored rule is in effect and its anchoring cannot be shown here, so+    /// displaying a default would depict an anchoring that is not the stored one+    /// as if it were. A pending or unauthorable resolution carries no pair to+    /// default from, so those sides read undecided too.+    ///+    /// Takes the slot's resolution rather than deriving it, so both of a slot's+    /// rows are read off one derivation. `nonisolated`: pure, and exercised from+    /// a nonisolated test suite.+    public nonisolated static func anchorRowState(+        anchoring: SlotAnchoring, resolution: URLLocatorResolution?, side: AnchorSide+    ) -> AnchorRowState {+        guard anchoring.retainedLocator == nil || anchoring.edited else { return .undecided }+        if let chosen = side == .left ? anchoring.left : anchoring.right {+            return AnchorRowState(anchor: chosen, isDefault: false)+        }+        guard case .locator(.pathBracketed(let left, let right))? = resolution+        else { return .undecided }+        return AnchorRowState(anchor: side == .left ? left : right, isDefault: true)+    }++    /// Appended to the option the default function would pick for a side the+    /// reader has not chosen, so the value on screen is not mistaken for a+    /// decision that was made.+    public static func anchorOptionLabel(+        side: AnchorSide, anchor: PathAnchor, isDefault: Bool+    ) -> String {+        let phrase = describe(side: side, anchor: anchor)+        return isDefault ? "\(phrase) (default)" : phrase+    }++    /// The plain-language phrase for one side's anchoring — the wording the+    /// candidate summary uses, so the menu and the description of what it+    /// authors read the same (Req 1.10, Q23).+    public static func describe(side: AnchorSide, anchor: PathAnchor) -> String {+        switch side {+        case .left: describe(left: anchor)+        case .right: describe(right: anchor)+        }+    }++    /// `nonisolated`: called from `ComposedTeachingViewModel`'s nonisolated+    /// locator descriptions, and pure.+    public nonisolated static func describe(left anchor: PathAnchor) -> String {+        switch anchor {+        case .start: "at the start of the path"+        case .literal(let value): "after “\(value.value)”"+        case .unanchored: "whatever comes before it"+        // Refused by validation; described rather than crashed on.+        case .end: "at the end of the path"+        }+    }++    /// `nonisolated` for the same reason as `describe(left:)`.+    public nonisolated static func describe(right anchor: PathAnchor) -> String {+        switch anchor {+        case .end: "at the end of the path"+        case .literal(let value): "before “\(value.value)”"+        case .unanchored: "whatever follows it"+        // Refused by validation; described rather than crashed on.+        case .start: "at the start of the path"+        }+    }++    /// Req 3.7 (Q24): the edge text a title rule's trims drop, named in the+    /// title section so it is visible before the commit. Nil when the rule+    /// carries no trim.+    public static func trimCaption(prefix: String?, suffix: String?) -> String? {+        let front = prefix.flatMap { $0.isEmpty ? nil : "“\($0)” from the front" }+        let back = suffix.flatMap { $0.isEmpty ? nil : "“\($0)” from the end" }+        switch (front, back) {+        case (let front?, let back?): return "Dropping \(front) and \(back)."+        case (let front?, nil): return "Dropping \(front)."+        case (nil, let back?): return "Dropping \(back)."+        case (nil, nil): return nil+        }+    }      /// Character ranges of the maximal alphanumeric runs in a component — the     /// tappable tokens of the split editor. Everything between is separator text.@@ -133,9 +316,28 @@ public enum ComposedTeachingPresentation {         public let trimSuffix: String?     } +    /// Which arm of the inference table a marking state lands in. Reported+    /// beside the rule by `inferenceOutcome` so tests pin the branch actually+    /// taken rather than a restatement of the trigger (Reqs 3.2, 3.3).+    enum TitleInferenceBranch: Equatable, Sendable {+        /// The selection authors no legal rule; the caller keeps it unreachable.+        case unauthorable+        /// The entire title marked as Work.+        case wholeTitle+        /// Whole-segment markings only — the positional forms.+        case segmentForm+        /// The positional form carrying exact edge trims (Req 3.2).+        case edgeTrim+        /// Part-marked Work only — whole title plus the discarded edge text.+        case wholeTitleWithTrims+        /// Part-marked Work and chapter — exact literals around two fields.+        case phrase+    }+     /// The example title's delimiter-split segments with their character ranges.     /// A title the tokenizer rejects is one segment covering the whole title.-    public static func titleSegments(in title: String) -> [TitleSegment] {+    /// `nonisolated`: reached from the nonisolated `inferenceOutcome`, and pure.+    public nonisolated static func titleSegments(in title: String) -> [TitleSegment] {         let whole = [TitleSegment(text: title, range: 0..<title.count)]         guard case .success(let tokenized) = DelimiterTokenizer.tokenize(title),               tokenized.segments.count == tokenized.delimiters.count + 1 else { return whole }@@ -198,26 +400,50 @@ public enum ComposedTeachingPresentation {     }      /// Infers the title rule form from what the reader selected (Req 8.6's-    /// table). Returns nil for a selection that cannot author a legal rule; the-    /// caller uses that to make such selections unreachable rather than-    /// reporting a validation error afterwards.+    /// table, widened by Reqs 3.1–3.5). Returns nil for a selection that cannot+    /// author a legal rule; the caller uses that to make such selections+    /// unreachable rather than reporting a validation error afterwards.     public static func inferredTitleRule(         title: String, segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole]     ) -> InferredTitleRule? {-        guard !chips.isEmpty, chips.count == roles.count else { return nil }+        inferenceOutcome(title: title, segments: segments, chips: chips, roles: roles).rule+    }++    /// The inference pipeline, reporting the branch it took beside the rule.+    /// The order is: structural guard → reclassify (Req 3.1) → semantic guards+    /// → early branches → the edge-trim branch (Req 3.2) → today's remaining+    /// branches over the reclassified chips (Req 3.3). `nonisolated`: exercised+    /// from a nonisolated test fixture, and pure.+    nonisolated static func inferenceOutcome(+        title: String, segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole]+    ) -> (branch: TitleInferenceBranch, rule: InferredTitleRule?) {+        guard !chips.isEmpty, chips.count == roles.count else { return (.unauthorable, nil) }+        let (chips, roles) = reclassified(segments: segments, chips: chips, roles: roles)+         let workIndices = roles.indices.filter { roles[$0] == .work }         let chapterIndices = roles.indices.filter { roles[$0] == .chapter }-        guard !workIndices.isEmpty, isContiguous(workIndices), isContiguous(chapterIndices) else { return nil }+        guard !workIndices.isEmpty, isContiguous(workIndices), isContiguous(chapterIndices)+        else { return (.unauthorable, nil) }          // Entire title marked as Work → whole-title rule, no trims.         if chapterIndices.isEmpty, workIndices.count == chips.count {-            return InferredTitleRule(definition: .wholeTitle, trimPrefix: nil, trimSuffix: nil)+            return (.wholeTitle, InferredTitleRule(definition: .wholeTitle, trimPrefix: nil, trimSuffix: nil))         }          let selectionUsesParts = (workIndices + chapterIndices).contains { chips[$0].isPart }         if !selectionUsesParts {-            return segmentFormRule(+            guard let rule = segmentFormRule(                 segments: segments, chips: chips, roles: roles, hasChapter: !chapterIndices.isEmpty)+            else { return (.unauthorable, nil) }+            return (.segmentForm, rule)+        }++        // Whole segments beside an edge part-marking → the positional form+        // carrying the discarded edge text as exact trims (Req 3.2). A+        // derivation the guards refuse falls through to the branches below,+        // which keeps the selection authorable rather than dead.+        if let rule = edgeTrimRule(title: title, segments: segments, chips: chips, roles: roles) {+            return (.edgeTrim, rule)         }          let workSpan = chips[workIndices.first!].range.lowerBound..<chips[workIndices.last!].range.upperBound@@ -225,7 +451,9 @@ public enum ComposedTeachingPresentation {             // Subdivided parts, Work only → whole-title rule plus the trims the             // discarded leading and trailing text authors.             let trims = WholeTitleRuleDeriver.trims(from: title, keptSpan: workSpan)-            return InferredTitleRule(definition: .wholeTitle, trimPrefix: trims.prefix, trimSuffix: trims.suffix)+            return (+                .wholeTitleWithTrims,+                InferredTitleRule(definition: .wholeTitle, trimPrefix: trims.prefix, trimSuffix: trims.suffix))         }         // Subdivided parts, Work and chapter → phrase. `.phrase` already means         // "exact literals around and between two fields", so this needs no new@@ -234,8 +462,53 @@ public enum ComposedTeachingPresentation {         let chapterSpan = chips[chapterIndices.first!].range.lowerBound             ..< chips[chapterIndices.last!].range.upperBound         guard let definition = try? PhrasePatternDeriver.derive(-            from: title, selection: PhraseSelection(chapter: chapterSpan, work: workSpan)) else { return nil }-        return InferredTitleRule(definition: definition, trimPrefix: nil, trimSuffix: nil)+            from: title, selection: PhraseSelection(chapter: chapterSpan, work: workSpan))+        else { return (.unauthorable, nil) }+        return (.phrase, InferredTitleRule(definition: definition, trimPrefix: nil, trimSuffix: nil))+    }++    /// Req 3.1: a subdivided segment whose part chips are all kept and marked+    /// with one field role collapses back to a single whole-segment chip+    /// carrying that role and the segment's full text — edge punctuation+    /// included (Q18). Every later stage sees the collapsed row, which is what+    /// keeps a fully-kept segment from ever counting as an edge part-marking.+    /// `nonisolated`: called from the nonisolated `inferenceOutcome`, and pure.+    nonisolated static func reclassified(+        segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole]+    ) -> (chips: [TitleChip], roles: [SegmentRole]) {+        var resultChips: [TitleChip] = []+        var resultRoles: [SegmentRole] = []+        var index = 0+        while index < chips.count {+            let chip = chips[index]+            guard chip.isPart else {+                resultChips.append(chip)+                resultRoles.append(roles[index])+                index += 1+                continue+            }+            var end = index+            while end < chips.count, chips[end].isPart, chips[end].segmentIndex == chip.segmentIndex {+                end += 1+            }+            let group = index..<end+            let groupRoles = Set(group.map { roles[$0] })+            if groupRoles.count == 1, let role = groupRoles.first, role != .ignore,+               segments.indices.contains(chip.segmentIndex) {+                let segment = segments[chip.segmentIndex]+                resultChips.append(TitleChip(+                    text: segment.text, range: segment.range,+                    segmentIndex: chip.segmentIndex, isPart: false, canSubdivide: true))+                resultRoles.append(role)+            } else {+                for position in group {+                    resultChips.append(chips[position])+                    resultRoles.append(roles[position])+                }+            }+            index = end+        }+        return (resultChips, resultRoles)     }      /// The chip selection that reproduces a Work span and an optional chapter@@ -265,29 +538,153 @@ public enum ComposedTeachingPresentation {      // MARK: - Title selection helpers (private) -    private static func segmentFormRule(+    /// `nonisolated`: called from the nonisolated `inferenceOutcome`, and pure.+    private nonisolated static func segmentFormRule(         segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole], hasChapter: Bool     ) -> InferredTitleRule? {-        var segmentRoles = [SegmentRole](repeating: .ignore, count: segments.count)+        guard let definition = segmentDefinition(+            texts: segments.map(\.text),+            segmentRoles: segmentRoles(chips: chips, roles: roles, count: segments.count),+            hasChapter: hasChapter)+        else { return nil }+        return InferredTitleRule(definition: definition, trimPrefix: nil, trimSuffix: nil)+    }++    /// One role per segment, taken from the whole-segment chips only: a part chip+    /// belongs to a subdivided segment whose role the caller decides (the+    /// edge-trim branch gives the segment its kept run's role). Segments no whole+    /// chip covers stay `.ignore`. `nonisolated`: reached from the nonisolated+    /// `inferenceOutcome`, and pure.+    private nonisolated static func segmentRoles(+        chips: [TitleChip], roles: [SegmentRole], count: Int+    ) -> [SegmentRole] {+        var segmentRoles = [SegmentRole](repeating: .ignore, count: count)         for (index, chip) in chips.enumerated() where !chip.isPart {             segmentRoles[chip.segmentIndex] = roles[index]         }-        let texts = segments.map(\.text)+        return segmentRoles+    }++    /// The positional definition one role per segment authors, shared by the+    /// whole-segment branch and the edge-trim branch — the latter passing the+    /// **post-trim** segment texts (Req 3.4, Q10). `nonisolated`: reached from+    /// the nonisolated `inferenceOutcome`, and pure.+    private nonisolated static func segmentDefinition(+        texts: [String], segmentRoles: [SegmentRole], hasChapter: Bool+    ) -> PatternDefinition? {         guard hasChapter else {-            // Whole segments, Work only → chapter-less segment rule. Positional-            // matching survives title variation that exact literals do not.+            // Work only → chapter-less segment rule. Positional matching+            // survives title variation that exact literals do not.             let workSegments = segmentRoles.indices.filter { segmentRoles[$0] == .work }             guard let first = workSegments.first, let last = workSegments.last,                   isContiguous(workSegments),                   let anchor = AnchorDerivation.deriveWorkAnchor(-                    segmentCount: segments.count, workRange: first..<(last + 1)) else { return nil }-            return InferredTitleRule(-                definition: .chapterlessSegment(work: anchor, ignored: []), trimPrefix: nil, trimSuffix: nil)+                    segmentCount: texts.count, workRange: first..<(last + 1)) else { return nil }+            return .chapterlessSegment(work: anchor, ignored: [])         }         let assignment = SegmentRoleAssignment(roles: segmentRoles)         guard case .success(let validated) = TeachingValidator.validate(assignment: assignment, segments: texts),               let definition = PatternDeriver.deriveSegmentPattern(from: validated) else { return nil }-        return InferredTitleRule(definition: definition, trimPrefix: nil, trimSuffix: nil)+        return definition+    }++    /// The positional-form-plus-trims rule an edge part-marking authors+    /// (Req 3.2), or nil when the trigger does not fire or a guard refuses the+    /// derivation. Expects the **reclassified** chip row. `nonisolated`: called+    /// from the nonisolated `inferenceOutcome`, and pure.+    private nonisolated static func edgeTrimRule(+        title: String, segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole]+    ) -> InferredTitleRule? {+        // Decision 3: the trigger needs at least one whole-segment field+        // marking beside the edge run, which a single-segment title can never+        // supply.+        guard segments.count >= 2,+              chips.indices.contains(where: { !chips[$0].isPart && roles[$0] != .ignore })+        else { return nil }+        let firstIndex = 0+        let lastIndex = segments.count - 1++        let markedParts = chips.indices.filter { chips[$0].isPart && roles[$0] != .ignore }+        guard !markedParts.isEmpty else { return nil }+        let prefixRun = edgeRun(chips: chips, roles: roles, segmentIndex: firstIndex, trailing: true)+        let suffixRun = edgeRun(chips: chips, roles: roles, segmentIndex: lastIndex, trailing: false)+        // Every marked part must lie inside one of the two runs, which is also+        // what confines part-marking to the title's edges.+        var runIndices: Set<Int> = []+        if let prefixRun { runIndices.formUnion(prefixRun) }+        if let suffixRun { runIndices.formUnion(suffixRun) }+        guard markedParts.allSatisfy(runIndices.contains) else { return nil }++        // Req 3.5: each trim is the exact source text between the title's edge+        // and the kept run, whitespace and delimiters included.+        let characters = Array(title)+        var trimPrefix: String?+        var keptLower = 0+        if let prefixRun {+            keptLower = chips[prefixRun.lowerBound].range.lowerBound+            // An empty slice is no trim at all, so the run must leave text+            // behind it (Req 3.2's non-empty discarded text).+            guard keptLower > 0 else { return nil }+            trimPrefix = String(characters[0..<keptLower])+        }+        var trimSuffix: String?+        var keptUpper = characters.count+        if let suffixRun {+            keptUpper = chips[suffixRun.upperBound - 1].range.upperBound+            guard keptUpper < characters.count else { return nil }+            trimSuffix = String(characters[keptUpper..<characters.count])+        }+        guard keptLower < keptUpper else { return nil }++        // Req 3.4: the anchors come from the title as it reads after the trims,+        // re-tokenized — deriving them from the original title authors a wrong+        // rule whenever the trimmed text contains a separator (Q10).+        let trimmed = String(characters[keptLower..<keptUpper])+        let trimmedSegments = titleSegments(in: trimmed)+        guard trimmedSegments.count == segments.count else { return nil }+        for index in trimmedSegments.indices {+            let expected: String+            if index == firstIndex, trimPrefix != nil {+                expected = String(characters[keptLower..<segments[index].range.upperBound])+            } else if index == lastIndex, trimSuffix != nil {+                expected = String(characters[segments[index].range.lowerBound..<keptUpper])+            } else {+                expected = segments[index].text+            }+            guard trimmedSegments[index].text == expected else { return nil }+        }++        // Segment roles carry over positionally; a trimmed edge segment takes+        // its kept run's role.+        var perSegment = segmentRoles(chips: chips, roles: roles, count: segments.count)+        if let prefixRun { perSegment[firstIndex] = roles[prefixRun.lowerBound] }+        if let suffixRun { perSegment[lastIndex] = roles[suffixRun.lowerBound] }++        guard let definition = segmentDefinition(+            texts: trimmedSegments.map(\.text), segmentRoles: perSegment,+            hasChapter: perSegment.contains(.chapter)) else { return nil }+        return InferredTitleRule(+            definition: definition, trimPrefix: trimPrefix, trimSuffix: trimSuffix)+    }++    /// The marked run of one edge segment's part chips, as chip indices, when it+    /// is a contiguous proper subset of that segment's parts carrying one field+    /// role and sitting flush against the segment's inner side (Req 3.2). Nil+    /// when the segment is not subdivided, carries no kept chip, or its marking+    /// is not such a run. `nonisolated`: reached from the nonisolated+    /// `inferenceOutcome`, and pure.+    private nonisolated static func edgeRun(+        chips: [TitleChip], roles: [SegmentRole], segmentIndex: Int, trailing: Bool+    ) -> Range<Int>? {+        let group = chips.indices.filter { chips[$0].isPart && chips[$0].segmentIndex == segmentIndex }+        guard let groupFirst = group.first, let groupLast = group.last else { return nil }+        let marked = group.filter { roles[$0] != .ignore }+        guard let first = marked.first, let last = marked.last,+              marked.count < group.count,+              Set(marked.map { roles[$0] }).count == 1,+              isContiguous(marked),+              trailing ? last == groupLast : first == groupFirst else { return nil }+        return first..<(last + 1)     }      private static func contiguousRuns(of role: SegmentRole, in roles: [SegmentRole]) -> [Range<Int>] {@@ -305,7 +702,8 @@ public enum ComposedTeachingPresentation {         return runs     } -    private static func isContiguous(_ indices: [Int]) -> Bool {+    /// `nonisolated`: reached from the nonisolated `inferenceOutcome`, and pure.+    private nonisolated static func isContiguous(_ indices: [Int]) -> Bool {         guard let first = indices.first, let last = indices.last else { return true }         return indices.count == last - first + 1     }@@ -316,7 +714,8 @@ public enum ComposedTeachingPresentation {             || (span.upperBound > segment.lowerBound && span.upperBound < segment.upperBound)     } -    private static func characterOffsetsByScalarOffset(in title: String) -> [Int: Int] {+    /// `nonisolated`: reached from the nonisolated `titleSegments`, and pure.+    private nonisolated static func characterOffsetsByScalarOffset(in title: String) -> [Int: Int] {         var offsets: [Int: Int] = [:]         var scalarOffset = 0         for (characterOffset, character) in title.enumerated() {
Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift Modified +244 / −37
diff --git a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swiftindex 643f5bd..fc80a1e 100644--- a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift+++ b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift@@ -99,6 +99,11 @@ public final class ComposedTeachingViewModel {     /// The tap is still refused rather than accepted and then reported (Req 8.6);     /// this only replaces the silence. Cleared by the next selection change.     public private(set) var titleSelectionNotice: String?+    /// Set when a stored title rule could not be reproduced as a chip selection+    /// on this title (Req 3.8). The stored rule stays in effect and the editor+    /// keeps its default selection; cleared by the first title edit, which+    /// replaces the stored rule.+    public private(set) var storedTitleRuleNotice: String?      // Articles (Req 8.7) — separate from title selection     /// Whether the reader has opened the Articles affordance's confirmation.@@ -108,6 +113,14 @@ public final class ComposedTeachingViewModel {      // URL rule (absorbed from URLTeachingViewModel)     public private(set) var urlRuleDefinition: URLRuleDefinition?+    /// What the URL editor currently authors (Q22). The optional definition alone+    /// cannot distinguish "the reader cleared the URL rule" from "the anchoring is+    /// half-chosen", and the two must gate the commit differently, so the editor+    /// publishes a typed status and the definition follows it.+    ///+    /// The invariant, held from the first frame: `urlRuleDefinition` is the+    /// status's `.valid` payload and nil for every other case.+    public private(set) var urlRuleStatus: ComposedTeachingPresentation.URLRuleStatus = .cleared      // Acknowledgment (Req 2.1, Q3)     public private(set) var acknowledgedUnsettled: Bool = false@@ -208,6 +221,12 @@ public final class ComposedTeachingViewModel {     /// The exact trailing trim the current selection authors (nil when none).     public var trimSuffix: String? { effectiveTitleRule?.trimSuffix } +    /// Req 3.7: the dropped edge text of the rule the surface would commit,+    /// named for the caption row beside the chips. Nil when it carries no trim.+    public var titleTrimCaption: String? {+        ComposedTeachingPresentation.trimCaption(prefix: trimPrefix, suffix: trimSuffix)+    }+     /// Whether the current rule set would leave chapters with no source and thus     /// needs the per-commit acknowledgment (Req 2.1).     public var requiresUnsettledAcknowledgment: Bool {@@ -217,9 +236,37 @@ public final class ComposedTeachingViewModel {     /// The **live** rule described in plain language: the candidate the reader is     /// authoring, or the retained selection while the disclosure is collapsed     /// (Q11) so a committed selection is never silently hidden.+    ///+    /// An unfinished URL rule says so here rather than reading as no selection at+    /// all — the summary is the only thing on screen while the disclosure is+    /// collapsed, and nil is what "cleared" looks like (Q22).     public var urlSelectionSummary: String? {-        guard let urlRuleDefinition else { return nil }-        return Self.describe(urlRuleDefinition)+        switch urlRuleStatus {+        case .cleared: nil+        case .valid(let definition): Self.describe(definition)+        case .pending(let message), .unauthorable(let message): message+        }+    }++    /// Why the URL side is blocking the commit, or nil when it is not (Req 1.8).+    /// Rendered beside the confirm control, so the explanation survives a+    /// collapsed disclosure.+    public var urlAnchoringPendingMessage: String? {+        switch urlRuleStatus {+        case .cleared, .valid: nil+        case .pending(let message), .unauthorable(let message): message+        }+    }++    /// Whether the URL side authors something the commit can act on: a finished+    /// rule, or nothing at all. False while an anchoring is half-chosen or a+    /// selection is unauthorable.+    private var urlRuleSettled: Bool { urlAnchoringPendingMessage == nil }++    /// The disclosure is held open while the URL side blocks the commit: the+    /// controls that release it are inside it.+    public var urlDisclosureExpanded: Bool {+        disclosureState == .expanded || !urlRuleSettled     }      /// The **stored** rule described in plain language, frozen at load so it stays@@ -465,8 +512,14 @@ public final class ComposedTeachingViewModel {     /// The removal warning, or nil. Shown while the stored rule declares the     /// sequence optional and the candidate does not — the moment before the     /// reader commits the removal.+    ///+    /// Only a settled candidate removes anything (Q22). While an anchoring is+    /// half-chosen the definition is nil, and reading that as a removal tells the+    /// reader mid-gesture — the Req 1.7 flow reaches it on the first side chosen+    /// — what a rule they have not authored would cost.     public var sequencePresenceRemovalMessage: String? {-        guard Self.declaresOptionalSequence(frozenBasis?.currentURLRule?.definition),+        guard urlRuleSettled,+              Self.declaresOptionalSequence(frozenBasis?.currentURLRule?.definition),               !Self.declaresOptionalSequence(urlRuleDefinition)         else { return nil }         return Self.sequencePresenceRemovalWarning@@ -483,12 +536,23 @@ public final class ComposedTeachingViewModel {     /// unsettled-chapters gate.     public var chapterUnsourced: Bool {         let titleChapter = effectiveTitleRule?.definition.producesChapter ?? false+        // An unfinished URL rule is not an absent one (Q22). Reading it as+        // "cleared" here would swap the disclosure to the chapter-remedy nudge+        // mid-edit, on a selection that may well source the chapter once the+        // reader finishes it.+        guard urlRuleSettled else { return false }         let urlChapter = urlRuleDefinition?.suppliesSequence ?? false         return !titleChapter && !urlChapter     } +    /// Req 1.8's commit gate: a half-chosen anchoring or an unauthorable+    /// selection refuses the commit with the explanation `urlAnchoringPendingMessage`+    /// carries. No preview is generated in those states either, so `contract` is+    /// already nil — this states the gate rather than relying on that.     public var canConfirm: Bool {-        guard !articlesRequested, state == .previewReady, previewGeneration == generation else { return false }+        guard !articlesRequested, urlRuleSettled, state == .previewReady,+              previewGeneration == generation+        else { return false }         return contract != nil     } @@ -541,6 +605,10 @@ public final class ComposedTeachingViewModel {                 seedTitleEditor(from: currentTitle)             }             if let currentURL = initial.basis.currentURLRule {+                // The status/definition invariant holds from the first frame+                // (Q22): a stored rule opens as `.valid`, an untaught site as+                // `.cleared`, which is what `urlRuleStatus` already is.+                urlRuleStatus = .valid(currentURL.definition)                 urlRuleDefinition = currentURL.definition                 disclosureState = .expanded             } else if entryContext == .urlFocused {@@ -577,6 +645,24 @@ public final class ComposedTeachingViewModel {         disclosureState == .expanded ? collapseDisclosure() : expandDisclosure()     } +    /// The one authority behind the disclosure's binding, so what the reader sees+    /// and what the model records cannot disagree.+    ///+    /// A collapse is **ignored** while the URL side blocks the commit (Req 1.8):+    /// `urlDisclosureExpanded` holds the section open regardless, so honouring+    /// the tap would change nothing on screen while recording a collapse — and,+    /// with it, a `didAutoExpandForChapter` reading taken under the pending+    /// guard's forced `chapterUnsourced == false`. The section would then snap+    /// shut the moment the reader completed the anchoring pair. Expanding is+    /// always honoured.+    public func setURLDisclosureExpanded(_ expanded: Bool) {+        if expanded {+            expandDisclosure()+        } else if urlRuleSettled {+            collapseDisclosure()+        }+    }+     // MARK: - Title chip selection (Req 3.3, 8.3, 8.6)      /// Cycles a chip's role Work → chapter → ignore. Roles that would leave a@@ -716,18 +802,37 @@ public final class ComposedTeachingViewModel {      // MARK: - URL rule editing (absorbed) -    /// Sets the current URL rule definition (or nil to clear) and re-previews. The-    /// view derives the definition from chip selections; the model holds it and-    /// composes it into the single preview.-    /// Sets the URL rule definition, or clears it with nil — the editor's-    /// explicit clear is the only way to remove a made selection (Q11).-    public func setURLRuleDefinition(_ definition: URLRuleDefinition?) {-        urlRuleDefinition = definition+    /// Takes what the URL editor authors — a finished rule, a deliberate clear, or+    /// the reason it is neither (Q22) — and re-previews.+    ///+    /// The order is the one `setURLRuleDefinition` has always kept: store, then+    /// invalidate, then re-sync the chapter-remedy disclosure, then preview. The+    /// preview itself declines to run on an unsettled status.+    public func updateURLRule(_ outcome: ComposedTeachingPresentation.URLRuleOutcome) {+        urlRuleStatus = outcome.status+        urlRuleDefinition = outcome.definition         invalidatePreview()         syncChapterRemedyDisclosure()         Task { await generatePreviewIfValid() }     } +    /// Sets the URL rule definition, or clears it with nil — the editor's+    /// explicit clear is the only way to remove a made selection (Q11).+    ///+    /// A delegating shim over `updateURLRule`: a definition is a `.valid` status+    /// and nil a `.cleared` one, which is exactly what the callers that predate+    /// the typed status mean.+    ///+    /// **Test seam.** Production code calls `updateURLRule`, which is the only+    /// entry point that can express a pending or unauthorable selection; this is+    /// kept so the six pre-status suites need not rebuild their outcomes. New+    /// callers belong on `updateURLRule`.+    public func setURLRuleDefinition(_ definition: URLRuleDefinition?) {+        updateURLRule(+            ComposedTeachingPresentation.URLRuleOutcome(+                status: definition.map { .valid($0) } ?? .cleared))+    }+     // MARK: - Acknowledgment (Req 2.1, Q3)      /// Records the per-commit unsettled-chapters acknowledgment and commits. The@@ -964,9 +1069,16 @@ public final class ComposedTeachingViewModel {     // MARK: - Request building      /// Builds the composed request from the current selections, or nil if the-    /// title selection is not yet a valid rule.+    /// title selection is not yet a valid rule — or the URL side is unsettled.+    ///+    /// Req 1.8's gate lives here rather than at each projection site: a+    /// half-chosen anchoring authors no rule, and previewing without it would+    /// show the reader the consequences of a rule they are not authoring. Every+    /// path that projects builds its request through here, the acknowledgment+    /// and retry paths included, and each already answers a nil request with+    /// `invalidateContractOnly()`. `canConfirm` keeps its own explicit check.     private func buildRequest() -> ComposedTeachingRequest? {-        guard let title = effectiveTitleRule else { return nil }+        guard urlRuleSettled, let title = effectiveTitleRule else { return nil }         return ComposedTeachingRequest(             titleDefinition: title.definition,             trimPrefix: title.trimPrefix,@@ -1019,6 +1131,9 @@ public final class ComposedTeachingViewModel {     private func commitTitleEdit() {         titleEdited = true         titleSelectionNotice = nil+        // The stored rule stops being the one in effect the moment the reader+        // edits, so its notice goes with it (Req 3.8).+        storedTitleRuleNotice = nil         invalidatePreview()         syncChapterRemedyDisclosure()         Task { await generatePreviewIfValid() }@@ -1064,9 +1179,11 @@ public final class ComposedTeachingViewModel {             let span = keptSpan(forTrimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix)             applySelection(workSpan: span, chapterSpan: nil)         case .segment(let work, let ignored):-            applySegmentSelection(work: work, ignored: ignored, chapterFromRemainder: true)+            applySegmentSelection(+                rule: rule, work: work, ignored: ignored, chapterFromRemainder: true)         case .chapterlessSegment(let work, let ignored):-            applySegmentSelection(work: work, ignored: ignored, chapterFromRemainder: false)+            applySegmentSelection(+                rule: rule, work: work, ignored: ignored, chapterFromRemainder: false)         case .phrase, .chapterlessPhrase:             if let spans = phraseSpans(for: rule) {                 applySelection(workSpan: spans.work, chapterSpan: spans.chapter)@@ -1086,8 +1203,14 @@ public final class ComposedTeachingViewModel {     }      private func applySegmentSelection(-        work: SegmentRangeSpec, ignored: [SegmentPositionSpec], chapterFromRemainder: Bool+        rule: ComposedTitleRuleBasis, work: SegmentRangeSpec, ignored: [SegmentPositionSpec],+        chapterFromRemainder: Bool     ) {+        guard rule.trimPrefix == nil, rule.trimSuffix == nil else {+            applyTrimmedSegmentSelection(+                rule: rule, work: work, ignored: ignored, chapterFromRemainder: chapterFromRemainder)+            return+        }         guard let restored = Self.roles(             work: work, ignored: ignored, segmentCount: titleSegments.count,             remainder: chapterFromRemainder ? .chapter : .ignore) else { return }@@ -1096,6 +1219,105 @@ public final class ComposedTeachingViewModel {         titleRoles = restored     } +    /// Seed a segment-form rule that carries trims (Req 3.8). The markings seed+    /// only where the reconstructed selection re-derives the stored definition+    /// **and** both trims exactly; on any mismatch the editor keeps its default+    /// whole-title selection and says the stored rule is the one in effect,+    /// rather than presenting a different rule as if it were the stored one.+    private func applyTrimmedSegmentSelection(+        rule: ComposedTitleRuleBasis, work: SegmentRangeSpec, ignored: [SegmentPositionSpec],+        chapterFromRemainder: Bool+    ) {+        guard let selection = trimmedSegmentSelection(+                rule: rule, work: work, ignored: ignored,+                chapterFromRemainder: chapterFromRemainder),+              let inferred = ComposedTeachingPresentation.inferredTitleRule(+                title: exampleTitle, segments: titleSegments,+                chips: selection.chips, roles: selection.roles),+              // The comparator, not `==`: trims compare by exact scalars, and a+              // canonically-equal but byte-distinct definition is the same rule.+              // This is the seeding half of the projection's own faithfulness+              // check (`ComposedTeachingProjection.titleVersionProjection`).+              RuleDefinitionComparator.semanticallyEqual(inferred.definition, rule.definition),+              RuleDefinitionComparator.trimsEqual(inferred.trimPrefix, rule.trimPrefix),+              RuleDefinitionComparator.trimsEqual(inferred.trimSuffix, rule.trimSuffix)+        else {+            storedTitleRuleNotice = ComposedTeachingPresentation.storedTitleRuleNotice+            return+        }+        subdividedSegments = selection.subdivided+        titleChips = selection.chips+        titleRoles = selection.roles+    }++    /// The chip selection a trimmed segment rule would be authored from: the+    /// trims located in this title (`TitleTrimApplicator`, never `hasPrefix`),+    /// their boundaries mapped onto part chips of the edge segments, and the+    /// stored anchors inverted against the **post-trim** segment count. Nil+    /// where any of those cannot be expressed.+    private func trimmedSegmentSelection(+        rule: ComposedTitleRuleBasis, work: SegmentRangeSpec, ignored: [SegmentPositionSpec],+        chapterFromRemainder: Bool+    ) -> (subdivided: Set<Int>, chips: [ComposedTeachingPresentation.TitleChip], roles: [SegmentRole])? {+        guard let first = titleSegments.first, let last = titleSegments.last,+              let kept = TitleTrimApplicator.keptCharacterRange(+                prefix: rule.trimPrefix, suffix: rule.trimSuffix, in: exampleTitle)+        else { return nil }+        let lastIndex = titleSegments.count - 1++        var subdivided: Set<Int> = []+        if rule.trimPrefix != nil {+            guard kept.lowerBound < first.range.upperBound,+                  partBoundaries(ofSegment: 0).starts.contains(kept.lowerBound) else { return nil }+            subdivided.insert(0)+        } else if kept.lowerBound != first.range.lowerBound {+            return nil+        }+        if rule.trimSuffix != nil {+            guard kept.upperBound > last.range.lowerBound,+                  partBoundaries(ofSegment: lastIndex).ends.contains(kept.upperBound) else { return nil }+            subdivided.insert(lastIndex)+        } else if kept.upperBound != last.range.upperBound {+            return nil+        }++        // The stored anchors index the trimmed title's segments (Req 3.4), so+        // the inversion runs against that count and maps back positionally.+        let trimmed = TitleTrimApplicator.apply(+            prefix: rule.trimPrefix, suffix: rule.trimSuffix, to: exampleTitle)+        let trimmedSegments = ComposedTeachingPresentation.titleSegments(in: trimmed)+        guard trimmedSegments.count == titleSegments.count,+              let segmentRoles = Self.roles(+                work: work, ignored: ignored, segmentCount: trimmedSegments.count,+                remainder: chapterFromRemainder ? .chapter : .ignore) else { return nil }++        let chips = ComposedTeachingPresentation.titleChips(+            segments: titleSegments, subdividing: subdivided)+        let roles: [SegmentRole] = chips.map { chip in+            let role = segmentRoles[chip.segmentIndex]+            guard chip.isPart else { return role }+            let isKept = chip.range.lowerBound >= kept.lowerBound+                && chip.range.upperBound <= kept.upperBound+            return isKept ? role : .ignore+        }+        return (subdivided, chips, roles)+    }++    /// The character offsets at which one segment's parts start and end — the+    /// only boundaries a chip selection can express. Empty for a segment the+    /// chip row never subdivides (fewer than two parts).+    ///+    /// Asked of the chip builder itself rather than re-derived from token ranges+    /// and a segment offset: the boundaries a seed may land on are exactly the+    /// chips the reader would be able to tap, and two derivations of that are one+    /// too many.+    private func partBoundaries(ofSegment index: Int) -> (starts: Set<Int>, ends: Set<Int>) {+        let parts = ComposedTeachingPresentation+            .titleChips(segments: titleSegments, subdividing: [index])+            .filter { $0.isPart && $0.segmentIndex == index }+        return (Set(parts.map { $0.range.lowerBound }), Set(parts.map { $0.range.upperBound }))+    }+     /// The character spans a phrase rule selected in this example title, when the     /// rule still parses it. `.phrase` is `prefix + FIELD + separator + FIELD +     /// suffix`, so the parsed field lengths locate both spans exactly; a title the@@ -1185,11 +1407,16 @@ public final class ComposedTeachingViewModel {     /// The locator half of the description, naming every literal anchor and     /// stating which side is unanchored — "the component after `series`, whatever     /// follows it" (Req 2.2).+    ///+    /// The per-side phrases come from `ComposedTeachingPresentation` (Q23): the+    /// anchoring menus label their options with them, and one wording has to+    /// serve both surfaces or the control and the description of what it authors+    /// drift apart.     nonisolated static func describe(_ locator: URLComponentLocator) -> String {         switch locator {         case .pathBracketed(let left, let right):             if left == .start, right == .end { return "the path's only component" }-            return "the component \(describe(left: left)), \(describe(right: right))"+            return "the component \(ComposedTeachingPresentation.describe(left: left)), \(ComposedTeachingPresentation.describe(right: right))"         case .query(let name):             return "the “\(name.value)” query item"         case .importedV2Path(let origin, let offset):@@ -1197,24 +1424,4 @@ public final class ComposedTeachingViewModel {             return "path component \(offset + 1) from the \(end) of the path (an imported rule)"         }     }--    private nonisolated static func describe(left anchor: PathAnchor) -> String {-        switch anchor {-        case .start: "at the start of the path"-        case .literal(let value): "after “\(value.value)”"-        case .unanchored: "whatever comes before it"-        // Refused by validation; described rather than crashed on.-        case .end: "at the end of the path"-        }-    }--    private nonisolated static func describe(right anchor: PathAnchor) -> String {-        switch anchor {-        case .end: "at the end of the path"-        case .literal(let value): "before “\(value.value)”"-        case .unanchored: "whatever follows it"-        // Refused by validation; described rather than crashed on.-        case .start: "at the start of the path"-        }-    } }
Asterism/Asterism/Views/ComposedURLDetailsEditor.swift Modified +232 / −25
diff --git a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swiftindex d091858..a746368 100644--- a/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift+++ b/Asterism/Asterism/Views/ComposedURLDetailsEditor.swift@@ -21,7 +21,10 @@ struct ComposedURLDetailsEditor: View {     /// the reader re-teaches, so the stored rule and the candidate can be compared     /// (Req 2.2) — it deliberately does not follow the live edit.     let storedSummary: String?-    let onRuleChange: (URLRuleDefinition?) -> Void+    /// What the selections author: a finished rule, a deliberate clear, or the+    /// reason they are neither (Q22). An optional definition cannot say which,+    /// and pending has to gate the commit with an explanation.+    let onRuleChange: (ComposedTeachingPresentation.URLRuleOutcome) -> Void      /// The selection state and its transitions live in     /// `ComposedTeachingPresentation.URLEditorState` so the reader's own gesture@@ -29,9 +32,23 @@ struct ComposedURLDetailsEditor: View {     /// dispatch, and nothing else.     @State private var state = ComposedTeachingPresentation.URLEditorState()     @State private var splitErrorMessage: String?-    /// The definition the local selections were last seeded from, so an external-    /// change (including an explicit clear) re-seeds rather than drifting.-    @State private var seededFrom: URLRuleDefinition??+    /// A refused chip tap or anchoring choice, in the reader's words (Reqs 1.3,+    /// 1.4). Owned by the view and cleared by the next gesture, like+    /// `splitErrorMessage`: a refusal describes a gesture, not a state, and must+    /// not outlive it.+    @State private var anchoringNotice: String?+    /// The status the local selections were last seeded from — or last published+    /// — so an external change (including an explicit clear) re-seeds rather than+    /// drifting. Nil means "never seeded".+    ///+    /// The **status**, not its definition: `.cleared`, `.pending` and+    /// `.unauthorable` all carry no definition, and a seed recorded as "no+    /// definition" cannot say which of the three the editor last published. The+    /// comparison below is still on the definition the status carries, because+    /// the definition is all the view model hands back — a pending publication's+    /// nil is this editor's own echo, not an external clear, and re-seeding on it+    /// would wipe the half-chosen anchoring mid-gesture.+    @State private var seededFrom: ComposedTeachingPresentation.URLRuleStatus?      typealias Slot = ComposedTeachingPresentation.URLSlot @@ -55,6 +72,7 @@ struct ComposedURLDetailsEditor: View {                 exampleURLSection(components)                 slotPicker                 chipSections(components)+                anchoringSections(components)                 candidateSummary                 splitSection(components)                 selectionHints@@ -77,12 +95,22 @@ struct ComposedURLDetailsEditor: View {     /// on appear and whenever the definition changes from outside the editor     /// (re-teach load, or the surface's explicit clear).     private func seedIfNeeded() {-        guard seededFrom != .some(currentDefinition) else { return }-        seededFrom = .some(currentDefinition)-        splitErrorMessage = nil+        if let seededFrom, seededFrom.definition == currentDefinition { return }+        seededFrom = currentDefinition.map { .valid($0) } ?? .cleared+        beginGesture()         state.seed(from: currentDefinition, in: components)     } +    /// Every gesture starts here, and so does a re-seed. The two transient+    /// messages describe a gesture, not a state, and must not outlive it — so+    /// both are retired before the next one is applied, in one place rather than+    /// at every handler that remembered to. The refusing paths set their notice+    /// *after* this runs.+    private func beginGesture() {+        anchoringNotice = nil+        splitErrorMessage = nil+    }+     // MARK: - Example URL      @ViewBuilder@@ -97,8 +125,19 @@ struct ComposedURLDetailsEditor: View {         }     } +    /// Switching slots is a gesture like any other, so it retires the transient+    /// messages — which described a gesture into the slot being left.+    private var activeSlotBinding: Binding<Slot> {+        Binding(+            get: { state.activeSlot },+            set: { slot in+                beginGesture()+                state.activeSlot = slot+            })+    }+     private var slotPicker: some View {-        Picker("Select for", selection: $state.activeSlot) {+        Picker("Select for", selection: activeSlotBinding) {             Text(ComposedTeachingPresentation.workSlotLabel).tag(Slot.work)             Text(ComposedTeachingPresentation.sequenceSlotLabel).tag(Slot.sequence)         }@@ -138,6 +177,122 @@ struct ComposedURLDetailsEditor: View {         }     } +    // MARK: - Anchoring (Reqs 1.1, 1.3, 1.7, 1.10, Q21)++    /// One block per slot holding a selected path component, plus the transient+    /// refusal notice. Two always-visible menu rows per block: Req 1.1's offer+    /// stays on screen and the candidate summary beneath says what it authors, so+    /// a disclosure would only hide the question the undecided state asks.+    @ViewBuilder+    private func anchoringSections(_ components: RawURLLexicalComponents) -> some View {+        anchoringBlock(slot: .work, in: components)+        anchoringBlock(slot: .sequence, in: components)+        if let anchoringNotice {+            // Q37: amber, not system red — the palette has no error colour.+            Text(anchoringNotice).font(.caption).foregroundStyle(AsterismColors.amberText)+                .accessibilityIdentifier("composed-url-anchoring-notice")+        }+    }++    @ViewBuilder+    private func anchoringBlock(slot: Slot, in components: RawURLLexicalComponents) -> some View {+        let anchoring = state.anchoring(for: slot)+        // One derivation for the whole block: both rows read their display state+        // off this single resolution, so the two sides cannot disagree and the+        // slot is resolved once per render rather than once per row.+        let resolution = state.resolution(for: slot, in: components)+        // Req 1.7's second branch: while the stored locator is in effect the+        // menus say nothing about it — showing the default's values would depict+        // an anchoring that is not the stored one as if it were.+        let storedNotShown = anchoring.retainedLocator != nil && !anchoring.edited+        // A query chip carries its own name and has no sides to anchor+        // (Non-Goals), so only a selected path component gets rows. A slot+        // occupied by a stored locator that seeds no chip still gets the notice.+        if case .path(let index)? = state.selection(for: slot) {+            VStack(alignment: .leading, spacing: 8) {+                anchoringHeader(slot: slot, storedNotShown: storedNotShown)+                anchorRow(+                    slot: slot, side: .left, at: index, in: components,+                    anchoring: anchoring, resolution: resolution)+                anchorRow(+                    slot: slot, side: .right, at: index, in: components,+                    anchoring: anchoring, resolution: resolution)+            }+            .accessibilityElement(children: .contain)+            .accessibilityIdentifier("composed-url-anchoring-\(Self.identifier(for: slot))")+        } else if storedNotShown {+            VStack(alignment: .leading, spacing: 8) {+                anchoringHeader(slot: slot, storedNotShown: true)+            }+            .accessibilityElement(children: .contain)+            .accessibilityIdentifier("composed-url-anchoring-\(Self.identifier(for: slot))")+        }+    }++    @ViewBuilder+    private func anchoringHeader(slot: Slot, storedNotShown: Bool) -> some View {+        Text(ComposedTeachingPresentation.slotLabel(for: slot))+            .font(.caption).foregroundStyle(.secondary)+        if storedNotShown {+            Text(ComposedTeachingPresentation.storedAnchoringNotShownNotice)+                .font(.caption).foregroundStyle(AsterismColors.amberText)+                .fixedSize(horizontal: false, vertical: true)+                .accessibilityIdentifier(+                    "composed-url-stored-anchoring-\(Self.identifier(for: slot))")+        }+    }++    /// One side's menu. What it shows is `anchorRowState`'s answer — the reader's+    /// choice where one stands, otherwise the default in force (a pair that+    /// applies must be visible), and "Choose…" where the slot is undecided or+    /// holds a stored locator the controls cannot show. The rule itself is pure+    /// and pinned by tests; this renders it.+    @ViewBuilder+    private func anchorRow(+        slot: Slot, side: ComposedTeachingPresentation.AnchorSide, at index: Int,+        in components: RawURLLexicalComponents,+        anchoring: ComposedTeachingPresentation.SlotAnchoring,+        resolution: ComposedTeachingPresentation.URLLocatorResolution?+    ) -> some View {+        let row = ComposedTeachingPresentation.anchorRowState(+            anchoring: anchoring, resolution: resolution, side: side)+        let offered = ComposedTeachingPresentation.offeredAnchors(+            side: side, at: index, in: components)+        LabeledContent(ComposedTeachingPresentation.anchorSideLabel(side)) {+            Menu {+                ForEach(Array(offered.enumerated()), id: \.offset) { _, anchor in+                    Button(ComposedTeachingPresentation.anchorOptionLabel(+                        side: side, anchor: anchor,+                        isDefault: row.isDefault && anchor == row.anchor)+                    ) {+                        choose(slot: slot, side: side, anchor: anchor, in: components)+                    }+                }+            } label: {+                Text(row.anchor.map { ComposedTeachingPresentation.describe(side: side, anchor: $0) }+                     ?? ComposedTeachingPresentation.anchorUndecidedLabel)+                    .font(.footnote)+                    .frame(minHeight: AsterismLayout.minHitTarget)+            }+        }+        .accessibilityIdentifier(+            "composed-url-anchor-\(Self.identifier(for: slot))-\(Self.identifier(for: side))")+    }++    private static func identifier(for slot: Slot) -> String {+        switch slot {+        case .work: "work"+        case .sequence: "sequence"+        }+    }++    private static func identifier(for side: ComposedTeachingPresentation.AnchorSide) -> String {+        switch side {+        case .left: "left"+        case .right: "right"+        }+    }+     /// The candidate's own description, beside the candidate (Req 2.2). This is     /// what makes a rule pinned to one story's filename visible at teach time.     @ViewBuilder@@ -161,9 +316,11 @@ struct ComposedURLDetailsEditor: View {         }         if workSelection != nil || sequenceSelection != nil {             Button("Clear URL selection") {+                beginGesture()                 state.clear()-                splitErrorMessage = nil-                publish(nil)+                // The one deliberate clear (Q11) — and the escape from a pending+                // anchoring that needs no new gesture.+                publish(ComposedTeachingPresentation.URLRuleOutcome(status: .cleared))             }             .font(.footnote)             .frame(minHeight: AsterismLayout.minHitTarget)@@ -255,7 +412,7 @@ struct ComposedURLDetailsEditor: View {                 splitEditor(text: text, split: split)             } else if text.count >= 3 {                 Button {-                    splitErrorMessage = nil+                    beginGesture()                     state.beginSplit(of: text)                     dispatchRuleDefinition()                 } label: {@@ -270,7 +427,7 @@ struct ComposedURLDetailsEditor: View {                 .buttonStyle(.plain)                 .accessibilityIdentifier("composed-url-split-button")             }-            optionalSequenceToggle+            optionalSequenceToggle(components)         }     } @@ -285,8 +442,11 @@ struct ComposedURLDetailsEditor: View {     /// `validate`, so without it the reader would meet     /// `invalidURLDefinition` at projection time instead of a disabled control     /// (Decision 5).+    ///+    /// Takes the components its caller already parsed: the computed `components`+    /// re-parses the raw URL on every read.     @ViewBuilder-    private var optionalSequenceToggle: some View {+    private func optionalSequenceToggle(_ components: RawURLLexicalComponents) -> some View {         if splitSelection != nil || state.retainedTemplate != nil {             let permitted = state.canDeclareSequenceOptional(in: components)             VStack(alignment: .leading, spacing: 4) {@@ -312,6 +472,7 @@ struct ComposedURLDetailsEditor: View {         Binding(             get: { state.sequencePresence == .optional },             set: { isOptional in+                beginGesture()                 state.setSequencePresence(isOptional ? .optional : .required)                 dispatchRuleDefinition()             })@@ -329,8 +490,8 @@ struct ComposedURLDetailsEditor: View {                     .accessibilityIdentifier("composed-url-split-error")             }             Button("Use the whole component instead") {+                beginGesture()                 state.useWholeComponent()-                splitErrorMessage = nil                 dispatchRuleDefinition()             }             .font(.footnote)@@ -380,31 +541,77 @@ struct ComposedURLDetailsEditor: View {     }      private func toggleSplitToken(at index: Int, tokens: [Range<Int>]) {+        beginGesture()         state.toggleSplitToken(at: index, tokens: tokens)         dispatchRuleDefinition()     }      // MARK: - Selection and rule dispatch +    /// What a chip tap leaves behind: the words the editor owes the reader, and+    /// whether the selections it now holds are worth publishing.+    nonisolated struct ChipTapOutcome: Equatable {+        let notice: String?+        let dispatches: Bool+    }++    /// A reader's chip tap, as state and words rather than as view updates, so+    /// the refusal wiring is exercisable without a running view.+    ///+    /// Routed through `selectComponent`, the refusing entry point: on a component+    /// no anchoring the representation admits can single out, the tap is refused+    /// with a reason and changes nothing (Req 1.4). `select` — the unconditional+    /// form — stays for seeding, where Reqs 1.6 and 1.7 govern instead.+    nonisolated static func chipTap(+        _ selection: ComposedTeachingPresentation.URLComponentSelection,+        in components: RawURLLexicalComponents?,+        state: inout ComposedTeachingPresentation.URLEditorState+    ) -> ChipTapOutcome {+        guard let components else { return ChipTapOutcome(notice: nil, dispatches: false) }+        if let refusal = state.selectComponent(selection, in: components) {+            return ChipTapOutcome(notice: refusal.message, dispatches: false)+        }+        return ChipTapOutcome(notice: nil, dispatches: true)+    }+     private func select(_ selection: URLComponentSelection) {-        splitErrorMessage = nil-        state.select(selection)+        beginGesture()+        let outcome = Self.chipTap(selection, in: components, state: &state)+        // After the clear: a refusal describes *this* tap.+        anchoringNotice = outcome.notice+        guard outcome.dispatches else { return }+        dispatchRuleDefinition()+    }++    /// The reader pins one side of a slot's component (Reqs 1.1, 1.3, 1.11). A+    /// refusal names its ground and leaves the standing anchoring alone.+    private func choose(+        slot: Slot, side: ComposedTeachingPresentation.AnchorSide, anchor: PathAnchor,+        in components: RawURLLexicalComponents+    ) {+        beginGesture()+        if let refusal = state.chooseAnchor(slot: slot, side: side, anchor: anchor, in: components) {+            // After the clear: a refusal describes *this* choice.+            anchoringNotice = refusal.message+            return+        }         dispatchRuleDefinition()     } -    /// Publishes a locally-authored definition. Records it as the seed so the-    /// `onChange` that follows recognises it as the editor's own work and leaves-    /// the local selections alone — without this, every edit round-trips through-    /// the view model and re-seeds, clearing the split selection mid-edit.-    private func publish(_ definition: URLRuleDefinition?) {-        seededFrom = .some(definition)-        onRuleChange(definition)+    /// Publishes what the selections author. Records the outcome's status as the+    /// seed so the `onChange` that follows recognises it as the editor's own work+    /// and leaves the local selections alone — without this, every edit+    /// round-trips through the view model and re-seeds, clearing the split+    /// selection mid-edit.+    private func publish(_ outcome: ComposedTeachingPresentation.URLRuleOutcome) {+        seededFrom = outcome.status+        onRuleChange(outcome)     } -    /// Dispatches the URL rule definition the current selections author.+    /// Dispatches the rule — or the status — the current selections author.     private func dispatchRuleDefinition() {         let outcome = state.rule(in: components)         splitErrorMessage = outcome.splitErrorMessage-        publish(outcome.definition)+        publish(outcome)     } }
Asterism/Asterism/Views/ComposedTeachingView.swift Modified +42 / −5
diff --git a/Asterism/Asterism/Views/ComposedTeachingView.swift b/Asterism/Asterism/Views/ComposedTeachingView.swiftindex 26cbd3d..d7681e7 100644--- a/Asterism/Asterism/Views/ComposedTeachingView.swift+++ b/Asterism/Asterism/Views/ComposedTeachingView.swift@@ -67,8 +67,15 @@ struct ComposedTeachingView: View {             editorContent             if model.articlesRequested {                 articlesConfirmButton-            } else if model.state == .previewReady || model.state == .previewing {-                confirmButton+            } else {+                // Req 1.8: the reason the commit is refused sits where the+                // commit control does, so a collapsed disclosure cannot hide it.+                if let message = model.urlAnchoringPendingMessage {+                    urlAnchoringPendingRow(message)+                }+                if model.state == .previewReady || model.state == .previewing {+                    confirmButton+                }             }         }     }@@ -137,6 +144,25 @@ struct ComposedTeachingView: View {                     .accessibilityIdentifier("composed-title-selection-notice")             } +            if let notice = model.storedTitleRuleNotice {+                // Req 3.8: the stored rule is in effect but its selection cannot+                // be shown on this title, so the chips below are the default —+                // not a depiction of the stored rule.+                Label(notice, systemImage: "exclamationmark.circle")+                    .font(.caption)+                    .foregroundStyle(AsterismColors.amberText)+                    .accessibilityIdentifier("composed-title-stored-rule-notice")+            }++            if let caption = model.titleTrimCaption {+                // Req 3.7 (Q24): the trims the effective rule carries, named+                // where the reader can check them against the example title.+                Text(caption)+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityIdentifier("composed-title-trim-caption")+            }+             // Q36: the previews wear the same §2/§7 mapping as the chips they             // preview — the Work reads cyan, the chapter amber.             LabeledContent("Work will be named") {@@ -232,9 +258,11 @@ struct ComposedTeachingView: View {      private var urlDisclosure: some View {         DisclosureGroup(+            // Held open while the URL side blocks the commit (Req 1.8): the+            // controls that release it are the ones inside.             isExpanded: Binding(-                get: { model.disclosureState == .expanded },-                set: { $0 ? model.expandDisclosure() : model.collapseDisclosure() })+                get: { model.urlDisclosureExpanded },+                set: { model.setURLDisclosureExpanded($0) })         ) {             VStack(alignment: .leading, spacing: 12) {                 // The editor owns the single clear affordance@@ -244,7 +272,7 @@ struct ComposedTeachingView: View {                     exampleRawURL: model.exampleRawURL,                     currentDefinition: model.urlRuleDefinition,                     storedSummary: model.storedURLRuleDescription,-                    onRuleChange: { model.setURLRuleDefinition($0) })+                    onRuleChange: { model.updateURLRule($0) })                 // Req 2.5 (Q10): this capture's URL cannot express a combined                 // rule at all, said rather than left to a silent failure.                 if let notice = model.unteachableCombinedShapeMessage {@@ -439,6 +467,15 @@ struct ComposedTeachingView: View {      // MARK: - Confirm and terminal states +    /// The URL side's commit refusal, in the same amber caption style as every+    /// other teaching notice (Q37).+    private func urlAnchoringPendingRow(_ message: String) -> some View {+        Label(message, systemImage: "exclamationmark.triangle")+            .font(.caption).foregroundStyle(AsterismColors.amberText)+            .frame(maxWidth: .infinity, alignment: .leading)+            .accessibilityIdentifier("composed-url-anchoring-pending")+    }+     private var confirmButton: some View {         Button {             Task { await model.confirm() }
Packages/AsterismCore/Sources/AsterismCore/Models.swift Modified +26 / −0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex dc18dee..1ab1872 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -513,3 +513,29 @@ public final class URLRulePattern { }  } // extension AsterismSchemaV5++extension Work {+    /// Req 3.21's one rule, in one place: a reuse or claim refreshes the Work's+    /// parsed title — `lastParsedTitle` always, `displayTitle` only while its+    /// provenance is parsed, so a manual display title survives — and a blank or+    /// absent derived name refreshes nothing.+    ///+    /// The three sites that need it are the composed apply, the re-parse commit,+    /// and Recalculate's change detector, which has to ask the same question+    /// *without* writing: reading the rule as "title-matched reuses only" is what+    /// left an identity-matched Work holding its pre-trim name after a re-teach,+    /// and a detector that restates the rule by hand is how the two drift apart.+    ///+    /// - Parameter commit: whether to apply the refresh; `false` only asks.+    /// - Returns: whether the refresh changes anything.+    @discardableResult+    func refreshParsedTitle(to name: String?, commit: Bool) -> Bool {+        guard let name, !M2Unicode.isBlank(name) else { return false }+        let movesDisplayTitle = titleProvenance == .parsed && displayTitle != name+        let changes = lastParsedTitle != name || movesDisplayTitle+        guard commit else { return changes }+        lastParsedTitle = name+        if titleProvenance == .parsed { displayTitle = name }+        return changes+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift Modified +18 / −6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swiftindex 906273f..f53a6ae 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ComposedTeaching.swift@@ -438,6 +438,19 @@ extension LibraryRepository {                     break                 case .reuse(let id), .claim(let id):                     if entry.work?.id != id { return true }+                    // The assignment also refreshes the target Work's parsed+                    // title (Req 3.21), so a stale name is a change even when+                    // every key, sequence, identity and pointer already agrees.+                    // Without this comparison Recalculate answers `.noChanges`+                    // over a rename it would in fact apply, and Req 3.26's+                    // "apply exactly the confirmed preview" cannot repair a+                    // Work whose name a re-taught trim left behind.+                    if let target = workGroups[id] {+                        for work in target.rows+                        where work.refreshParsedTitle(to: derivation.workName, commit: false) {+                            return true+                        }+                    }                 case .create:                     return true                 }@@ -753,12 +766,11 @@ extension LibraryRepository {                     work.urlIdentityRuleID = url.id                     work.urlIdentityRuleVersion = url.version                 }-                // A title-matched reuse refreshes the parsed title (Req 3.21 analog).-                if derivation.workIdentity == nil, let name = derivation.workName,-                    !M2Unicode.isBlank(name) {-                    work.lastParsedTitle = name-                    if work.titleProvenance == .parsed { work.displayTitle = name }-                }+                // Req 3.21: *every* reuse or claim refreshes the Work's parsed+                // title, identity-matched included. The rule itself lives on+                // `Work` — the re-parse commit and Recalculate's change detector+                // apply and ask the same one.+                work.refreshParsedTitle(to: derivation.workName, commit: true)                 work.modifiedAt = timestamp             }             applyAssignmentProvenance(to: entry, derivation: derivation,
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift Modified +4 / −6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swiftindex 6a1105c..7f3c351 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift@@ -428,12 +428,10 @@ extension LibraryRepository {                     work.urlIdentityRuleID = url.id                     work.urlIdentityRuleVersion = url.version                 }-                if derivation.workIdentity == nil, let name = derivation.workName,-                    !M2Unicode.isBlank(name)-                {-                    work.lastParsedTitle = name-                    if work.titleProvenance == .parsed { work.displayTitle = name }-                }+                // Req 3.21, the same rule the composed apply path follows — the+                // one on `Work`, so the two cannot drift: an identity match+                // refreshes the parsed title too.+                work.refreshParsedTitle(to: derivation.workName, commit: true)                 work.modifiedAt = timestamp             }             applyAssignmentProvenance(to: entry, derivation: derivation,
Asterism/AsterismTests/ComposedURLEditorStateTests.swift Modified +1143 / −36
diff --git a/Asterism/AsterismTests/ComposedURLEditorStateTests.swift b/Asterism/AsterismTests/ComposedURLEditorStateTests.swiftindex 85b7e71..cb66a29 100644--- a/Asterism/AsterismTests/ComposedURLEditorStateTests.swift+++ b/Asterism/AsterismTests/ComposedURLEditorStateTests.swift@@ -21,6 +21,358 @@ struct ComposedURLEditorStateTests {      private static func literal(_ value: String) -> PathAnchor { .literal(ExactScalarString(value)) } +    /// A lexical component list built directly, so a path can carry blanks and+    /// repeated values without smuggling them through a URL string.+    private static func lexical(_ path: [String]) -> RawURLLexicalComponents {+        RawURLLexicalComponents(+            scheme: ExactScalarString("https"),+            hostname: ExactScalarString("example.test"),+            rawPath: ExactScalarString("/" + path.joined(separator: "/")),+            pathComponents: path.map(ExactScalarString.init),+            rawQuery: nil,+            queryItems: [])+    }++    // MARK: - Index-precise resolution (Q20, Req 1.3)++    /// Candidacy is blank-blind, exactly as `selectBracketed` is: core rejects a+    /// blank component only *after* the uniqueness check, so a blank component+    /// still occupies a candidate slot and a blank literal still matches.+    @Test("bracketedIndices restates core's predicate, blind to blankness")+    func bracketedIndicesIsBlankBlind() throws {+        let parsed = Self.lexical(["x", "", "x", "y"])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: Self.literal("x"), right: .unanchored, in: parsed) == [1, 3])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: Self.literal(""), right: .unanchored, in: parsed) == [2])++        // And core agrees: index 1 is blank yet still a candidate, so the pair is+        // ambiguous rather than resolving at index 3.+        #expect(+            (try? URLRuleApplicator.select(+                .pathBracketed(left: Self.literal("x"), right: .unanchored), from: parsed)) == nil)+        #expect(+            try URLRuleApplicator.select(+                .pathBracketed(left: Self.literal(""), right: .unanchored), from: parsed)+                == ExactScalarString("x"))+    }++    @Test("The degenerate anchors never match: left .end, right .start")+    func degenerateAnchorsNeverMatch() {+        let parsed = Self.lexical(["a", "b"])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .end, right: .unanchored, in: parsed).isEmpty)+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .unanchored, right: .start, in: parsed).isEmpty)+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .end, right: .start, in: parsed).isEmpty)+    }++    @Test("The boundary and unanchored anchors bracket what core brackets")+    func boundaryAnchors() {+        let parsed = Self.lexical(["a", "b", "c"])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .start, right: .unanchored, in: parsed) == [0])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .unanchored, right: .end, in: parsed) == [2])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .unanchored, right: .unanchored, in: parsed) == [0, 1, 2])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .start, right: .end, in: parsed).isEmpty)+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: Self.literal("a"), right: Self.literal("c"), in: parsed) == [1])+    }++    /// The differential property (design, Testing): exactly one bracketed index+    /// with a non-blank component there ⇔ `URLRuleApplicator.select` succeeds and+    /// returns that component's value. Exhaustive over every component list of+    /// length 1–4 drawn from `{a, b, blank}` — blanks and repeated values+    /// included — against every anchor pair.+    ///+    /// Looped rather than `@Test(arguments:)`, for the reason recorded above.+    @Test("bracketedIndices and URLRuleApplicator.select agree on every anchor pair")+    func bracketedIndicesMatchesCoreSelection() {+        var lists: [[String]] = [["x", "", "x", "y"]]+        var frontier: [[String]] = [[]]+        for _ in 0..<4 {+            frontier = frontier.flatMap { list in ["a", "b", ""].map { list + [$0] } }+            lists.append(contentsOf: frontier)+        }+        let anchors: [PathAnchor] = [+            .start, .end, .unanchored, Self.literal("a"), Self.literal("b"), Self.literal(""),+        ]+        for list in lists {+            let parsed = Self.lexical(list)+            for left in anchors {+                for right in anchors {+                    let indices = ComposedTeachingPresentation.bracketedIndices(+                        left: left, right: right, in: parsed)+                    let selected = try? URLRuleApplicator.select(+                        .pathBracketed(left: left, right: right), from: parsed)+                    let label = "\(list) \(left) \(right)"+                    if indices.count == 1, !parsed.pathComponents[indices[0]].isBlank {+                        #expect(selected == parsed.pathComponents[indices[0]], "\(label)")+                    } else {+                        #expect(selected == nil, "\(label)")+                    }+                }+            }+        }+    }++    // MARK: - The default function (Reqs 1.1, 1.2, 2.1, Decisions 4 and 5)++    private typealias Anchoring = ComposedTeachingPresentation.SlotAnchoring+    private typealias Resolution = ComposedTeachingPresentation.URLLocatorResolution++    private static func resolution(+        _ path: [String], _ index: Int, slot: ComposedTeachingPresentation.URLSlot = .work,+        left: PathAnchor? = nil, right: PathAnchor? = nil+    ) -> Resolution {+        ComposedTeachingPresentation.urlLocatorResolution(+            for: .path(index), slot: slot, anchoring: Anchoring(left: left, right: right),+            in: Self.lexical(path))+    }++    private static func bracketed(_ left: PathAnchor, _ right: PathAnchor) -> Resolution {+        .locator(.pathBracketed(left: left, right: right))+    }++    /// Req 1.1: the immediate neighbour's literal where it exists and is+    /// non-blank, the path boundary where the component is first or last, and+    /// unanchored — nothing else, and never a non-adjacent literal.+    @Test("Every applicable anchoring is offered, and only those")+    func offeredAnchorings() {+        let parsed = Self.lexical(["a", "", "b", "c"])+        func offered(_ side: ComposedTeachingPresentation.AnchorSide, _ index: Int) -> [PathAnchor] {+            ComposedTeachingPresentation.offeredAnchors(side: side, at: index, in: parsed)+        }+        #expect(offered(.left, 0) == [.start, .unanchored])+        #expect(offered(.right, 0) == [.unanchored])          // the neighbour is blank+        #expect(offered(.left, 2) == [.unanchored])           // ditto, on the left+        #expect(offered(.right, 2) == [Self.literal("c"), .unanchored])+        #expect(offered(.left, 3) == [Self.literal("b"), .unanchored])+        #expect(offered(.right, 3) == [.end, .unanchored])+    }++    /// Outcome 1: today's derivation, with the left scan reduced to the+    /// immediate neighbour (Decision 4).+    @Test("The preferred derivation wins where it resolves at the selected component")+    func preferredDerivationWins() {+        #expect(+            Self.resolution(["series", "the-regressor", "ep201"], 1)+                == Self.bracketed(Self.literal("series"), .unanchored))+        #expect(+            Self.resolution(["a", "b"], 1) == Self.bracketed(Self.literal("a"), .end))+        #expect(Self.resolution(["only"], 0) == Self.bracketed(.start, .end))+    }++    /// Req 2.1: the chapter slot's whole last component generalises across the+    /// site's works. It does **not** fire on any other component, and the work+    /// slot — where a split-derived chapter's locator also carries Work identity+    /// — keeps today's derivation.+    @Test("The chapter slot defaults the whole last component to (unanchored, .end)")+    func chapterSlotDefault() {+        let tapas = ["series", "the-regressor", "ep201"]+        #expect(Self.resolution(tapas, 2, slot: .sequence) == Self.bracketed(.unanchored, .end))+        #expect(+            Self.resolution(tapas, 2, slot: .work)+                == Self.bracketed(Self.literal("the-regressor"), .end))+        // Not the last component: today's derivation, in either slot.+        #expect(+            Self.resolution(tapas, 1, slot: .sequence)+                == Self.bracketed(Self.literal("series"), .unanchored))+        // A single-component path is the last component too.+        #expect(Self.resolution(["only"], 0, slot: .sequence) == Self.bracketed(.unanchored, .end))+        // Q5: a trailing slash makes the final *blank* component the last one, so+        // the default does not reach the component before it.+        #expect(+            Self.resolution(["a", "b", ""], 1, slot: .sequence)+                == Self.bracketed(Self.literal("a"), .unanchored))+    }++    /// Outcome 2, and the second Q10 defect retired: the old scan skipped the+    /// blank and authored `.literal("a")`, which the applicator — checking only+    /// the immediate neighbour — refuses against the very URL it was taught from.+    @Test("A blank neighbour falls to the sole valid anchoring")+    func blankNeighbourFallsToTheSoleCandidate() {+        #expect(Self.resolution(["a", "", "b"], 2) == Self.bracketed(.unanchored, .end))+        #expect(+            Self.resolution(["a", "", "b", "c"], 2)+                == Self.bracketed(.unanchored, Self.literal("c")))+    }++    /// Outcome 3: several anchorings qualify and they mean different things, so+    /// the reader decides (the Non-Goals reserve stable-vs-story-specific).+    @Test("A repeated value leaves the anchoring undecided")+    func repeatedValueOpensUndecided() {+        #expect(Self.resolution(["a", "x", "a", "y"], 1) == .pending(missing: [.left, .right]))+    }++    /// Outcome 4: on `/a//x//b` every offered pair is either refused by the+    /// representation or fails to resolve, so there is nothing to author.+    @Test("A component no anchoring can single out is unauthorable")+    func noValidAnchoringIsUnauthorable() {+        #expect(Self.resolution(["a", "", "x", "", "b"], 2) == .unauthorable)+    }++    @Test("A blank or out-of-range component is unauthorable")+    func blankComponentIsUnauthorable() {+        #expect(Self.resolution(["a", "", "b"], 1) == .unauthorable)+        #expect(Self.resolution(["a", "b", ""], 2) == .unauthorable)+        #expect(Self.resolution(["a"], 7) == .unauthorable)+    }++    /// The same preference completes a partially chosen pair, and an in-force+    /// side is never overridden.+    @Test("Completion honours the in-force side")+    func completionHonoursInForceSides() {+        let repeated = ["a", "x", "a", "y"]+        #expect(+            Self.resolution(repeated, 1, left: .unanchored)+                == Self.bracketed(.unanchored, Self.literal("a")))+        #expect(+            Self.resolution(repeated, 1, left: Self.literal("a"))+                == Self.bracketed(Self.literal("a"), Self.literal("a")))+        // Both sides in force: the pair itself, preference or not.+        #expect(+            Self.resolution(repeated, 1, left: .unanchored, right: Self.literal("a"))+                == Self.bracketed(.unanchored, Self.literal("a")))+    }++    @Test("A query selection authors its name, with or without anchoring state")+    func querySelectionResolution() throws {+        func item(_ name: String, _ value: String) -> RawURLQueryItem {+            RawURLQueryItem(+                raw: ExactScalarString("\(name)=\(value)"), name: ExactScalarString(name),+                value: ExactScalarString(value))+        }+        let parsed = RawURLLexicalComponents(+            scheme: ExactScalarString("https"), hostname: ExactScalarString("example.test"),+            rawPath: ExactScalarString("/a"), pathComponents: [ExactScalarString("a")],+            rawQuery: ExactScalarString("identity=42&=7"),+            queryItems: [item("identity", "42"), item("", "7")])+        #expect(+            ComposedTeachingPresentation.urlLocatorResolution(+                for: .query(0), slot: .work, anchoring: Anchoring(), in: parsed)+                == .locator(.query(name: ExactScalarString("identity"))))+        #expect(+            ComposedTeachingPresentation.urlLocatorResolution(+                for: .query(1), slot: .work, anchoring: Anchoring(), in: parsed) == .unauthorable)+        #expect(+            ComposedTeachingPresentation.urlLocatorResolution(+                for: .query(9), slot: .work, anchoring: Anchoring(), in: parsed) == .unauthorable)+    }++    /// Decision 5's totality, restated independently of the implementation:+    /// every non-blank component of every path lands in exactly one outcome, and+    /// a `.locator` outcome resolves at the selected index and nowhere else.+    ///+    /// Looped rather than `@Test(arguments:)`, for the reason recorded above.+    @Test("The default function is total, and its locators resolve at the selected index")+    func defaultFunctionTotality() {+        var lists: [[String]] = [["x", "", "x", "y"]]+        var frontier: [[String]] = [[]]+        for _ in 0..<4 {+            frontier = frontier.flatMap { list in ["a", "b", ""].map { list + [$0] } }+            lists.append(contentsOf: frontier)+        }+        for list in lists {+            let parsed = Self.lexical(list)+            for index in list.indices {+                for slot in [ComposedTeachingPresentation.URLSlot.work, .sequence] {+                    let outcome = ComposedTeachingPresentation.urlLocatorResolution(+                        for: .path(index), slot: slot, anchoring: Anchoring(), in: parsed)+                    let label = "\(list) @\(index) \(slot)"+                    guard !parsed.pathComponents[index].isBlank else {+                        #expect(outcome == .unauthorable, "\(label)")+                        continue+                    }+                    let valid = Self.validAnchorings(at: index, in: parsed)+                    let preferred = Self.preferred(at: index, slot: slot, in: parsed)+                    if valid.isEmpty {+                        #expect(outcome == .unauthorable, "\(label)")+                    } else if valid.contains(preferred) {+                        #expect(outcome == .locator(preferred), "\(label)")+                    } else if valid.count == 1 {+                        #expect(outcome == .locator(valid[0]), "\(label)")+                    } else {+                        #expect(outcome == .pending(missing: [.left, .right]), "\(label)")+                    }+                    if case .locator(let locator) = outcome {+                        guard case .pathBracketed(let left, let right) = locator else {+                            Issue.record("\(label): a path selection authored \(locator)")+                            continue+                        }+                        #expect(+                            ComposedTeachingPresentation.bracketedIndices(+                                left: left, right: right, in: parsed) == [index], "\(label)")+                        #expect(+                            (try? URLRuleApplicator.select(locator, from: parsed))+                                == parsed.pathComponents[index], "\(label)")+                        #expect(+                            (try? URLRuleDefinition.work(locator: locator).validate(+                                origin: .readerTaught, isCurrent: true)) != nil, "\(label)")+                    }+                }+            }+        }+    }++    /// Req 1.1's offer and Req 1.3's two grounds, restated in the test so the+    /// totality property is not merely the implementation echoed back.+    private static func validAnchorings(+        at index: Int, in components: RawURLLexicalComponents+    ) -> [URLComponentLocator] {+        let path = components.pathComponents+        var lefts: [PathAnchor] = []+        if index == 0 { lefts.append(.start) }+        if index > 0, !path[index - 1].isBlank { lefts.append(.literal(path[index - 1])) }+        lefts.append(.unanchored)+        var rights: [PathAnchor] = []+        if index + 1 < path.count, !path[index + 1].isBlank { rights.append(.literal(path[index + 1])) }+        if index == path.count - 1 { rights.append(.end) }+        rights.append(.unanchored)++        var valid: [URLComponentLocator] = []+        for left in lefts {+            for right in rights {+                let locator = URLComponentLocator.pathBracketed(left: left, right: right)+                guard+                    (try? URLRuleDefinition.work(locator: locator).validate(+                        origin: .readerTaught, isCurrent: true)) != nil,+                    ComposedTeachingPresentation.bracketedIndices(+                        left: left, right: right, in: components) == [index],+                    !path[index].isBlank+                else { continue }+                valid.append(locator)+            }+        }+        return valid+    }++    private static func preferred(+        at index: Int, slot: ComposedTeachingPresentation.URLSlot,+        in components: RawURLLexicalComponents+    ) -> URLComponentLocator {+        let path = components.pathComponents+        let isLast = index == path.count - 1+        if slot == .sequence, isLast { return .pathBracketed(left: .unanchored, right: .end) }+        let left: PathAnchor =+            index > 0 && !path[index - 1].isBlank ? .literal(path[index - 1]) : .start+        return .pathBracketed(left: left, right: isLast ? .end : .unanchored)+    }+     // MARK: - The builder default (Req 2.1, Decision 3)      @Test("The right side defaults to unanchored")@@ -46,15 +398,17 @@ struct ComposedURLEditorStateTests {                 == .pathBracketed(left: .start, right: .end))     } -    @Test("The left side keeps its current scan, blank-skipping included (Q10)")-    func leftSideUnchanged() throws {+    /// Deliberately reversed by Decision 4 (Req 4.2's exception list). The old+    /// left scan skipped the blank at index 1 and authored `.literal("a")`,+    /// which the applicator — checking only the immediate neighbour — refuses+    /// against the very URL it was taught from. The default now falls to the+    /// sole anchoring that does resolve there.+    @Test("The left scan no longer skips a blank neighbour (Decision 4, was Q10)")+    func leftScanStopsAtTheImmediateNeighbour() throws {         let parsed = try Self.components("https://example.test/a//b/c")-        // The blank component at index 1 is skipped by the left scan. That is a-        // shipped defect this feature deliberately does not fix (Q10); it is-        // pinned here so a later fix is a visible change, not a silent one.         #expect(             ComposedTeachingPresentation.urlLocator(for: .path(2), in: parsed)-                == .pathBracketed(left: Self.literal("a"), right: .unanchored))+                == .pathBracketed(left: .unanchored, right: Self.literal("c")))         #expect(             ComposedTeachingPresentation.urlLocator(for: .path(0), in: parsed)                 == .pathBracketed(left: .start, right: .unanchored))@@ -136,6 +490,721 @@ struct ComposedURLEditorStateTests {         }     } +    // MARK: - Transactional transitions (Reqs 1.3, 1.4, 1.9)++    private typealias Refusal = ComposedTeachingPresentation.TransitionRefusal++    /// Req 1.3's first ground. `(literal("a"), unanchored)` is a locator the+    /// representation admits perfectly well; it just brackets both `x` and `y`+    /// on this path, so it does not say which component the reader means.+    @Test("A choice that does not resolve at the selected component is refused as such")+    func choiceRefusedForNotResolving() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        var state = State()+        state.select(.path(1))+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: Self.literal("a"), in: parsed) == nil)+        let refusal = state.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: parsed)+        #expect(refusal?.ground == .doesNotResolve)+        #expect(refusal?.message == ComposedTeachingPresentation.anchoringDoesNotResolveNotice)+    }++    /// Req 1.3's second ground, and the case that makes the two distinct: on a+    /// single-component path the both-unanchored pair *resolves* — one candidate,+    /// non-blank — and is refused anyway, because the representation rejects it.+    @Test("Both sides unanchored is refused as representation-rejected, even where it resolves")+    func choiceRefusedAsUnrepresentable() {+        let single = Self.lexical(["only"])+        #expect(+            ComposedTeachingPresentation.bracketedIndices(+                left: .unanchored, right: .unanchored, in: single) == [0])+        var state = State()+        state.select(.path(0))+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: single) == nil)+        let refusal = state.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: single)+        #expect(refusal?.ground == .representationRejected)+        #expect(refusal?.message == ComposedTeachingPresentation.anchoringUnrepresentableNotice)++        // The same on a multi-component path, where it does not resolve either —+        // the representation still decides the ground.+        let pair = Self.lexical(["a", "b"])+        var other = State()+        other.select(.path(0))+        #expect(other.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: pair) == nil)+        #expect(+            other.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: pair)?.ground+                == .representationRejected)+    }++    /// Req 1.3's last sentence and Req 1.4's: a refusal leaves the previously+    /// in-force anchoring unchanged — and everything else with it, the retained+    /// template included.+    @Test("A refused transition has no side effects at all")+    func refusalsHaveNoSideEffects() throws {+        // A state carrying everything a refusal could quietly damage: a retained+        // template, a live split, and a standing anchoring choice.+        let tth = try Self.components(Self.tthURL)+        var state = State()+        state.seed(from: Self.tthStoredDefinition, in: tth)+        state.beginSplit(of: "Story-30975-1")+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: tth) == nil)+        let before = state++        // A refused choice: with the left standing unanchored, so is the right.+        #expect(+            state.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: tth)?.ground+                == .representationRejected)+        #expect(state == before)+        #expect(state.retainedTemplate == Self.tthTemplate)++        // A refused selection, which today's `select` would have answered by+        // clearing the split and the template on the way to authoring nothing.+        let blanks = Self.lexical(["a", "", "x", "", "b"])+        let refusal = state.selectComponent(.path(2), in: blanks)+        #expect(refusal?.ground == .noAuthorableAnchoring)+        #expect(refusal?.message == ComposedTeachingPresentation.componentUnauthorableNotice)+        #expect(state == before)+    }++    /// Req 1.4: no offered combination is valid for `x` on `/a//x//b`, so the+    /// selection itself is refused rather than authoring nothing in silence.+    @Test("A component no anchoring can single out cannot be selected")+    func selectionRefusedWhenNothingIsAuthorable() {+        let parsed = Self.lexical(["a", "", "x", "", "b"])+        var state = State()+        let before = state+        #expect(state.selectComponent(.path(2), in: parsed)?.ground == .noAuthorableAnchoring)+        #expect(state == before)+        // Its neighbours are fine.+        #expect(state.selectComponent(.path(0), in: parsed) == nil)+        #expect(state.work == .path(0))+    }++    /// The editor's own tap handler, not just the state transition under it: the+    /// chip button reaches the refusing entry point, keeps the reason in the+    /// notice, and publishes nothing on a refusal (Req 1.4). Wiring the button+    /// back to the unconditional `select` would author nothing in silence.+    @Test("The chip button's tap handler carries the refusal and withholds the dispatch")+    func chipTapCarriesTheRefusal() {+        let parsed = Self.lexical(["a", "", "x", "", "b"])+        var state = State()+        let before = state++        let refused = ComposedURLDetailsEditor.chipTap(.path(2), in: parsed, state: &state)+        #expect(refused.notice == ComposedTeachingPresentation.componentUnauthorableNotice)+        #expect(!refused.dispatches, "a refused tap authors nothing to publish")+        #expect(state == before)++        let accepted = ComposedURLDetailsEditor.chipTap(.path(0), in: parsed, state: &state)+        #expect(accepted.notice == nil, "an accepted tap retires the refusal")+        #expect(accepted.dispatches)+        #expect(state.work == .path(0))+    }++    /// Req 1.9, the rows of the lifecycle table reachable without a stored rule.+    /// The retained-locator rows are exercised beside seeding.+    @Test("An explicit choice survives a same-component tap and every split gesture")+    func explicitChoiceSurvivesOrthogonalGestures() throws {+        let parsed = try Self.components(Self.tthURL)+        // Both sides, and neither the default's: the default function authors+        // `(.start, .unanchored)` here, so a rule that reads back as the chosen+        // pair proves the anchoring is being *honoured* and not merely stored.+        let chosenRight = Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm")+        let chosenRule = URLRuleDefinition.work(+            locator: .pathBracketed(left: .unanchored, right: chosenRight))+        var state = State()+        #expect(state.selectComponent(.path(0), in: parsed) == nil)+        #expect(state.chooseAnchor(slot: .work, side: .right, anchor: chosenRight, in: parsed) == nil)+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)+        #expect(state.workAnchoring.right == chosenRight)+        #expect(state.workAnchoring.left == .unanchored)+        #expect(state.workAnchoring.edited)+        #expect(state.rule(in: parsed).definition == chosenRule)++        // The same chip again: a re-tap must not discard a chosen slot.+        #expect(state.selectComponent(.path(0), in: parsed) == nil)+        #expect(state.workAnchoring.right == chosenRight)+        #expect(state.workAnchoring.edited)+        #expect(state.rule(in: parsed).definition == chosenRule)++        // Splitting narrows *within* the component; anchoring locates it. The two+        // are orthogonal (Q11), so none of these touch it.+        state.beginSplit(of: "Story-30975-1")+        let tokens = ComposedTeachingPresentation.tokenRanges(in: "Story-30975-1")+        state.toggleSplitToken(at: tokens.count - 1, tokens: tokens)+        state.setSequencePresence(.optional)+        state.activeSlot = .sequence+        state.activeSlot = .work+        state.useWholeComponent()+        #expect(state.workAnchoring.right == chosenRight)+        #expect(state.workAnchoring.edited)+        #expect(state.rule(in: parsed).definition == chosenRule)++        // A different component replaces the locator, so the choice goes with it —+        // and the rule falls back to what the default function authors there.+        #expect(state.selectComponent(.path(1), in: parsed) == nil)+        #expect(state.workAnchoring == Anchoring())+        #expect(+            state.rule(in: parsed).definition+                == .work(locator: .pathBracketed(+                    left: Self.literal("Story-30975-1"), right: .end)))++        // As does an explicit clear, in both slots.+        #expect(state.chooseAnchor(slot: .work, side: .right, anchor: .end, in: parsed) == nil)+        state.clear()+        #expect(state.workAnchoring == Anchoring())+        #expect(state.sequenceAnchoring == Anchoring())+    }++    /// The sequence slot keeps its own anchoring on a same-index tap, and the two+    /// slots' anchorings never cross (Req 1.5).+    @Test("The sequence slot retains its anchoring on a same-index tap")+    func sequenceSlotSameIndexRetention() {+        let parsed = Self.lexical(["series", "story", "ep201"])+        var state = State()+        #expect(state.selectComponent(.path(1), in: parsed) == nil)+        state.activeSlot = .sequence+        #expect(state.selectComponent(.path(2), in: parsed) == nil)+        #expect(+            state.chooseAnchor(slot: .sequence, side: .left, anchor: Self.literal("story"), in: parsed)+                == nil)+        #expect(state.sequenceAnchoring.left == Self.literal("story"))+        #expect(state.workAnchoring == Anchoring(), "the Work slot's anchoring is untouched")++        #expect(state.selectComponent(.path(2), in: parsed) == nil)+        #expect(state.sequenceAnchoring.left == Self.literal("story"))+        #expect(state.sequenceAnchoring.edited)++        #expect(state.selectComponent(.path(1), in: parsed) == nil)+        #expect(state.sequenceAnchoring == Anchoring())+    }++    /// `chooseAnchor` asks validity with the other side fixed where one is in+    /// force and free where none is — so a first choice is judged against every+    /// completion, and a second against the standing one.+    @Test("A choice is judged against the free completions of the other side")+    func choiceValidityUsesTheOtherSidesOptions() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        var state = State()+        state.select(.path(1))+        // Free: `.unanchored` on the left is valid because `literal("a")` on the+        // right completes it, even though the unanchored/unanchored pair is not.+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)+        // Fixed: with the left standing at `.unanchored`, only `literal("a")`+        // remains, so `.end` — which does not apply here at all — is refused.+        #expect(state.chooseAnchor(slot: .work, side: .right, anchor: .end, in: parsed) != nil)+        #expect(+            state.chooseAnchor(slot: .work, side: .right, anchor: Self.literal("a"), in: parsed)+                == nil)+        #expect(state.workAnchoring.left == .unanchored)+        #expect(state.workAnchoring.right == Self.literal("a"))+    }++    // MARK: - Seeding a stored anchoring (Reqs 1.7, 1.8, 1.9, Decision 6)++    private static func queryComponents(_ items: [(String, String)]) -> RawURLLexicalComponents {+        RawURLLexicalComponents(+            scheme: ExactScalarString("https"), hostname: ExactScalarString("example.test"),+            rawPath: ExactScalarString("/read"), pathComponents: [ExactScalarString("read")],+            rawQuery: items.isEmpty ? nil : ExactScalarString("q"),+            queryItems: items.map {+                RawURLQueryItem(+                    raw: ExactScalarString("\($0.0)=\($0.1)"), name: ExactScalarString($0.0),+                    value: ExactScalarString($0.1))+            })+    }++    /// Req 1.7's first branch: the controls reflect the stored anchoring exactly+    /// when the stored pair brackets the chip the editor displays.+    @Test("A stored anchoring that brackets the displayed chip is reflected")+    func storedAnchoringIsReflected() throws {+        let parsed = try Self.components(Self.tthURL)+        var state = State()+        state.seed(from: Self.tthStoredDefinition, in: parsed)+        #expect(state.work == .path(0))+        #expect(state.workAnchoring.left == .start)+        #expect(+            state.workAnchoring.right+                == Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))+        #expect(state.workAnchoring.retainedLocator == nil)+        #expect(!state.workAnchoring.edited)+    }++    /// Req 1.7's second branch. The stored pair resolves at index 2 while the+    /// editor displays index 0 — the out-of-scope Q10 seeding defect — so the+    /// controls must not depict a different anchoring as if it were the stored+    /// one. The locator is retained instead.+    @Test("A stored anchoring hidden at the displayed chip is retained, not reflected")+    func hiddenAnchoringIsRetained() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        let hidden = URLComponentLocator.pathBracketed(left: Self.literal("x"), right: .unanchored)+        #expect(ComposedTeachingPresentation.bracketedIndices(+            left: Self.literal("x"), right: .unanchored, in: parsed) == [2])+        var state = State()+        state.seed(from: .work(locator: hidden), in: parsed)+        #expect(state.work == .path(0), "the chip lands on the first matching text (Q10)")+        #expect(state.workAnchoring.left == nil)+        #expect(state.workAnchoring.right == nil)+        #expect(state.workAnchoring.retainedLocator == hidden)+        #expect(!state.workAnchoring.edited)+    }++    /// A stored locator that seeds no chip at all — it resolves nowhere on this+    /// URL — still occupies its slot (Decision 6). This is the+    /// `m.fanfiction.net` reopen-from-chapter-2 case.+    @Test("A locator that resolves nowhere occupies its slot through the retained locator")+    func resolvesNowhereOccupiesTheSlot() {+        // Taught from `/s/14545097/1/chapter-1`, reopened on chapter 2.+        let parsed = Self.lexical(["s", "14545097", "2", "The-Club"])+        let chapterOne = URLComponentLocator.pathBracketed(+            left: Self.literal("14545097"), right: Self.literal("chapter-1"))+        var state = State()+        state.seed(+            from: .workAndSequence(+                work: URLFieldSelector(+                    locator: .pathBracketed(left: Self.literal("s"), right: .unanchored)),+                sequence: URLFieldSelector(locator: chapterOne)),+            in: parsed)+        #expect(state.work == .path(1))+        #expect(state.workAnchoring.left == Self.literal("s"))+        #expect(state.workAnchoring.retainedLocator == nil)+        // `.literal("chapter-1")` is nowhere on this URL, so the sequence side+        // seeds no chip — and must not vanish, or the stored rule narrows to+        // `.work` on the next dispatch.+        #expect(state.sequence == nil)+        #expect(state.sequenceAnchoring.retainedLocator == chapterOne)+    }++    /// Q25: retention covers every locator kind. A query item absent from the+    /// example URL is retained exactly as a hidden path anchoring is.+    @Test("A query locator whose item is absent is retained the same way")+    func absentQueryLocatorIsRetained() {+        let present = Self.queryComponents([("identity", "42")])+        let absent = Self.queryComponents([("other", "42")])+        let locator = URLComponentLocator.query(name: ExactScalarString("identity"))++        var seeded = State()+        seeded.seed(from: .work(locator: locator), in: present)+        #expect(seeded.work == .query(0))+        #expect(seeded.workAnchoring.retainedLocator == nil, "the chip already carries the name")++        var hidden = State()+        hidden.seed(from: .work(locator: locator), in: absent)+        #expect(hidden.work == nil)+        #expect(hidden.workAnchoring.retainedLocator == locator)+    }++    /// Req 1.9's last clause: a different capture resets the anchoring, edited+    /// flag included, so a choice never outlives the URL it was made on.+    @Test("Seeding resets the anchoring and the edited flag")+    func seedResetsAnchoring() throws {+        let parsed = try Self.components(Self.tthURL)+        var state = State()+        state.select(.path(0))+        #expect(state.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: parsed) == nil)+        #expect(state.workAnchoring.edited)++        state.seed(from: Self.tthStoredDefinition, in: parsed)+        #expect(!state.workAnchoring.edited)+        #expect(state.workAnchoring.right+            == Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))++        state.seed(from: nil, in: parsed)+        #expect(state.workAnchoring == Anchoring())+        #expect(state.sequenceAnchoring == Anchoring())+    }++    // MARK: - What the anchoring rows display (Req 1.7)++    /// Req 1.7's display half — "SHALL NOT depict a different anchoring as if it+    /// were the stored one" — pinned on the pure function the View renders,+    /// rather than left inside a `some View` where nothing can reach it.++    private static func rows(+        _ state: State, slot: ComposedTeachingPresentation.URLSlot = .work,+        in components: RawURLLexicalComponents+    ) -> (left: ComposedTeachingPresentation.AnchorRowState,+          right: ComposedTeachingPresentation.AnchorRowState) {+        let anchoring = state.anchoring(for: slot)+        let resolution = state.resolution(for: slot, in: components)+        return (+            ComposedTeachingPresentation.anchorRowState(+                anchoring: anchoring, resolution: resolution, side: .left),+            ComposedTeachingPresentation.anchorRowState(+                anchoring: anchoring, resolution: resolution, side: .right))+    }++    @Test("A reflected stored pair shows both anchors, neither marked as the default")+    func rowsShowAReflectedStoredPair() throws {+        let parsed = try Self.components(Self.tthURL)+        var state = State()+        state.seed(from: Self.tthStoredDefinition, in: parsed)++        let rows = Self.rows(state, in: parsed)+        #expect(rows.left.anchor == .start)+        #expect(rows.right.anchor+            == Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))+        // In force, not defaulted — even where the default function would author+        // the same value, which is the whole point of the distinction.+        #expect(!rows.left.isDefault)+        #expect(!rows.right.isDefault)+    }++    @Test("A retained stored locator shows nothing on either side, and no default")+    func rowsShowNothingForARetainedLocator() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        let hidden = URLComponentLocator.pathBracketed(left: Self.literal("x"), right: .unanchored)+        var state = State()+        state.seed(from: .work(locator: hidden), in: parsed)++        let rows = Self.rows(state, in: parsed)+        #expect(rows.left == ComposedTeachingPresentation.AnchorRowState(+            anchor: nil, isDefault: false))+        #expect(rows.right == ComposedTeachingPresentation.AnchorRowState(+            anchor: nil, isDefault: false))+        // The stored rule is still what the slot resolves to; it is simply not+        // depicted at the chip the editor displays.+        #expect(state.resolution(for: .work, in: parsed) == .locator(hidden))+    }++    @Test("An unchosen side shows the default in force, marked as the default")+    func rowsShowTheDefaultForAnUnchosenSide() {+        let parsed = Self.lexical(["series", "story", "12"])+        var state = State()+        #expect(state.selectComponent(.path(1), in: parsed) == nil)++        let rows = Self.rows(state, in: parsed)+        #expect(rows.left.anchor == Self.literal("series"))+        #expect(rows.left.isDefault)+        #expect(rows.right.anchor == .unanchored)+        #expect(rows.right.isDefault)+    }++    @Test("A chosen side stops being a default; the side it pends stays undecided")+    func rowsFollowAChosenSide() {+        let parsed = Self.lexical(["series", "story", "12"])+        var state = State()+        #expect(state.selectComponent(.path(1), in: parsed) == nil)+        #expect(+            state.chooseAnchor(slot: .work, side: .right, anchor: Self.literal("12"), in: parsed)+                == nil)++        let rows = Self.rows(state, in: parsed)+        #expect(rows.right.anchor == Self.literal("12"))+        #expect(!rows.right.isDefault, "a choice is not a default")+        // Two left-hand completions now qualify and they mean different things,+        // so the slot pends and the row says nothing rather than picking one+        // (Req 1.2's third outcome).+        #expect(state.resolution(for: .work, in: parsed) == .pending(missing: [.left]))+        #expect(rows.left == ComposedTeachingPresentation.AnchorRowState(+            anchor: nil, isDefault: false))+    }++    @Test("A half-edited retained slot shows the chosen side and nothing opposite")+    func rowsForAHalfEditedRetainedSlot() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        let hidden = URLComponentLocator.pathBracketed(left: Self.literal("x"), right: .unanchored)+        var state = State()+        state.seed(from: .work(locator: hidden), in: parsed)+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)++        let rows = Self.rows(state, in: parsed)+        #expect(rows.left.anchor == .unanchored)+        #expect(!rows.left.isDefault)+        #expect(rows.right == ComposedTeachingPresentation.AnchorRowState(+            anchor: nil, isDefault: false),+            "no auto-completion may displace an anchor the controls never showed")+    }++    // MARK: - Slot composition and the rule status (Reqs 1.5, 1.8, Q22, Q26)++    /// Req 1.8: while a pair is half-chosen the rule is neither the stored one+    /// nor a replacement, and the status has to say which side is missing.+    @Test("A half-chosen pair publishes a pending status naming the missing side")+    func pendingNamesTheMissingSide() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        var state = State()+        state.select(.path(1))+        guard case .pending(let both) = state.rule(in: parsed).status else {+            Issue.record("expected a pending status with no side chosen")+            return+        }+        #expect(both.contains("before it and after it"))+        #expect(both.contains(ComposedTeachingPresentation.workSlotLabel))++        // Choosing one side leaves exactly one valid completion here, so the+        // pending state closes without a second gesture.+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)+        #expect(+            state.rule(in: parsed).status+                == .valid(.work(locator: .pathBracketed(+                    left: .unanchored, right: Self.literal("a")))))+    }++    /// The pending path and the unauthorable path emit a status, not a+    /// definition — and neither performs the `.combined` branches' write-back,+    /// so a live split cannot stamp a template onto a slot that authors nothing.+    @Test("Neither the pending nor the unauthorable path writes the template back")+    func noWriteBackOnPendingOrUnauthorable() {+        let repeated = Self.lexical(["Story-1-2", "Story-1-2", "tail"])+        var pending = State()+        pending.select(.path(1))+        pending.beginSplit(of: "Story-1-2")+        pending.setSequencePresence(.optional)+        guard case .pending(let message) = pending.rule(in: repeated).status else {+            Issue.record("expected a pending status on the repeated value")+            return+        }+        #expect(message.contains("before it and after it"))+        #expect(pending.retainedTemplate == nil, "the split branch's write-back must not run")+        #expect(pending.sequencePresence == .optional)++        // The control: index 0 is unambiguous, so the same gestures do author a+        // combined rule, and do write the template back.+        var authored = State()+        authored.select(.path(0))+        authored.beginSplit(of: "Story-1-2")+        #expect(authored.rule(in: repeated).definition != nil)+        #expect(authored.retainedTemplate != nil)++        let dead = Self.lexical(["a", "", "x", "", "b"])+        var unauthorable = State()+        unauthorable.select(.path(2))+        unauthorable.beginSplit(of: "x")+        guard case .unauthorable = unauthorable.rule(in: dead).status else {+            Issue.record("expected an unauthorable status")+            return+        }+        #expect(unauthorable.retainedTemplate == nil)+    }++    /// Q26: the cross-slot clash keeps publishing a cleared status. Upgrading it+    /// to unauthorable would block a commit today permits.+    @Test("A cross-slot clash publishes cleared, not unauthorable")+    func crossSlotClashIsCleared() {+        let parsed = Self.lexical(["a", "b"])+        var state = State()+        state.select(.path(0))+        state.activeSlot = .sequence+        state.select(.path(0))+        let outcome = state.rule(in: parsed)+        #expect(outcome.status == .cleared)+        #expect(outcome.definition == nil)+    }++    /// Req 1.5: one anchoring applies per selected component, and choosing for+    /// one slot leaves the other slot's locator bit-identical.+    @Test("Choosing anchoring in one slot leaves the other slot's locator identical")+    func anchoringIsPerSlot() throws {+        let parsed = Self.lexical(["series", "story", "ep201"])+        var state = State()+        #expect(state.selectComponent(.path(1), in: parsed) == nil)+        state.activeSlot = .sequence+        #expect(state.selectComponent(.path(2), in: parsed) == nil)++        guard case .valid(.workAndSequence(let workBefore, let sequenceBefore)) =+            state.rule(in: parsed).status+        else {+            Issue.record("expected a two-locator rule")+            return+        }+        #expect(workBefore.locator+            == .pathBracketed(left: Self.literal("series"), right: .unanchored))+        #expect(sequenceBefore.locator == .pathBracketed(left: .unanchored, right: .end))++        // Unpinning the Work side from `series` leaves `ep201` as the only valid+        // completion — and leaves the chapter slot's locator exactly as it was.+        #expect(+            state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)+        guard case .valid(.workAndSequence(let workAfter, let sequenceAfter)) =+            state.rule(in: parsed).status+        else {+            Issue.record("expected a two-locator rule after the choice")+            return+        }+        #expect(workAfter.locator+            == .pathBracketed(left: .unanchored, right: Self.literal("ep201")))+        #expect(workAfter != workBefore)+        #expect(sequenceAfter == sequenceBefore)+    }++    /// Decision 6, end to end: while the reader has not touched the slot the+    /// stored locator is republished byte-identically by gestures that have+    /// nothing to do with anchoring.+    @Test("Orthogonal gestures republish a retained locator byte-identically")+    func retainedLocatorSurvivesOrthogonalGestures() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        let hidden = URLComponentLocator.pathBracketed(left: Self.literal("x"), right: .unanchored)+        let stored = URLRuleDefinition.combined(locator: hidden, template: Self.tthTemplate)+        var state = State()+        state.seed(from: stored, in: parsed)+        #expect(state.workAnchoring.retainedLocator == hidden)+        #expect(state.rule(in: parsed).definition == stored)++        state.setSequencePresence(.optional)+        #expect(+            state.rule(in: parsed).definition+                == .combined(locator: hidden, template: Self.tthOptionalTemplate))+        state.setSequencePresence(.required)+        state.activeSlot = .sequence+        state.activeSlot = .work+        #expect(state.rule(in: parsed).definition == stored, "byte-identical to the stored rule")+    }++    /// Q25: a hidden Work locator occupies its slot, so a stored+    /// `.workAndSequence` cannot narrow to `.sequence` — the defect class that+    /// moved 40 captures off their version-2 keys.+    @Test("A slot occupied only by a retained locator keeps the rule's form")+    func retainedLocatorOccupiesTheSlot() {+        let parsed = Self.lexical(["s", "14545097", "2", "The-Club"])+        let chapterOne = URLComponentLocator.pathBracketed(+            left: Self.literal("14545097"), right: Self.literal("chapter-1"))+        let stored = URLRuleDefinition.workAndSequence(+            work: URLFieldSelector(+                locator: .pathBracketed(left: Self.literal("s"), right: .unanchored)),+            sequence: URLFieldSelector(locator: chapterOne))+        var state = State()+        state.seed(from: stored, in: parsed)+        #expect(state.sequence == nil)+        #expect(state.rule(in: parsed).definition == stored)+    }++    /// Req 1.8, and the invariant that makes the slot-resolution table total:+    /// the first edit opens a pending replacement, and the transition that+    /// completes the pair is the one that drops the stored locator.+    @Test("Editing a retained slot pends, and completing the pair drops the stored locator")+    func editingARetainedSlotReplacesItExplicitly() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        let hidden = URLComponentLocator.pathBracketed(left: Self.literal("x"), right: .unanchored)+        var state = State()+        state.seed(+            from: .combined(locator: hidden, template: Self.tthTemplate), in: parsed)+        #expect(state.canDeclareSequenceOptional(in: parsed))++        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)+        #expect(state.workAnchoring.edited)+        #expect(state.workAnchoring.retainedLocator == hidden, "not dropped by a half-edit")+        guard case .pending(let message) = state.rule(in: parsed).status else {+            Issue.record("a half-edited retained slot must pend")+            return+        }+        #expect(message.contains("after it"))+        #expect(!message.contains("before it"))+        // A non-`.locator` resolution closes the declaration's gate too.+        #expect(!state.canDeclareSequenceOptional(in: parsed))+        // …and nothing was written back on the way.+        #expect(state.retainedTemplate == Self.tthTemplate)++        #expect(+            state.chooseAnchor(slot: .work, side: .right, anchor: Self.literal("x"), in: parsed)+                == nil)+        #expect(state.workAnchoring.retainedLocator == nil, "the completed pair replaces it")+        #expect(+            state.rule(in: parsed).definition+                == .combined(+                    locator: .pathBracketed(left: .unanchored, right: Self.literal("x")),+                    template: Self.tthTemplate))+    }++    /// Req 1.8's last clause: completing one locator's pair leaves another+    /// slot's stored locator untouched. The transition that drops a retained+    /// locator is `chooseAnchor`'s, and it must be scoped to the slot it was+    /// asked about — the sequence slot here holds a query locator absent from+    /// the example URL, retained under Q25.+    @Test("Completing one slot's pair leaves the other slot's retained locator untouched")+    func completingOnePairLeavesTheOtherSlotsRetainedLocator() {+        let parsed = Self.lexical(["a", "x", "a", "y"])+        let hiddenWork = URLComponentLocator.pathBracketed(+            left: Self.literal("x"), right: .unanchored)+        let absentSequence = URLComponentLocator.query(name: ExactScalarString("chapter"))+        let stored = URLRuleDefinition.workAndSequence(+            work: URLFieldSelector(locator: hiddenWork),+            sequence: URLFieldSelector(locator: absentSequence))+        var state = State()+        state.seed(from: stored, in: parsed)+        #expect(state.workAnchoring.retainedLocator == hiddenWork)+        #expect(state.sequence == nil)+        #expect(state.sequenceAnchoring.retainedLocator == absentSequence)+        #expect(state.rule(in: parsed).definition == stored)++        // Half-edited: the Work slot pends, and the chapter slot is not part of+        // the edit at all.+        #expect(state.chooseAnchor(slot: .work, side: .left, anchor: .unanchored, in: parsed) == nil)+        guard case .pending = state.rule(in: parsed).status else {+            Issue.record("a half-edited retained slot must pend")+            return+        }+        #expect(state.sequenceAnchoring == Anchoring(retainedLocator: absentSequence))++        // Completing the pair drops the Work slot's stored locator, and only it.+        #expect(+            state.chooseAnchor(slot: .work, side: .right, anchor: Self.literal("x"), in: parsed)+                == nil)+        #expect(state.workAnchoring.retainedLocator == nil)+        #expect(+            state.sequenceAnchoring == Anchoring(retainedLocator: absentSequence),+            "byte-identical to the stored locator, edited flag included")+        #expect(+            state.rule(in: parsed).definition+                == .workAndSequence(+                    work: URLFieldSelector(+                        locator: .pathBracketed(left: .unanchored, right: Self.literal("x"))),+                    sequence: URLFieldSelector(locator: absentSequence)),+            "the chapter slot still occupies the rule, with the stored locator")+    }++    /// Req 1.9's lifecycle table: a chip tap into a slot holding only a retained+    /// locator is a nil→component *replacement*, not a side edit, so the stored+    /// locator goes rather than pending on an anchoring the reader never saw.+    @Test("Selecting into a slot that holds only a retained locator drops it")+    func selectionIntoARetainedOnlySlotDropsTheLocator() {+        let parsed = Self.lexical(["s", "14545097", "2", "The-Club"])+        let chapterOne = URLComponentLocator.pathBracketed(+            left: Self.literal("14545097"), right: Self.literal("chapter-1"))+        let workLocator = URLComponentLocator.pathBracketed(+            left: Self.literal("s"), right: .unanchored)+        var state = State()+        state.seed(+            from: .workAndSequence(+                work: URLFieldSelector(locator: workLocator),+                sequence: URLFieldSelector(locator: chapterOne)),+            in: parsed)+        #expect(state.sequence == nil)+        #expect(state.sequenceAnchoring.retainedLocator == chapterOne)++        state.activeSlot = .sequence+        #expect(state.selectComponent(.path(2), in: parsed) == nil)+        #expect(state.sequence == .path(2))+        #expect(state.sequenceAnchoring == Anchoring(), "the stored locator is replaced outright")+        #expect(state.workAnchoring.left == Self.literal("s"), "the Work slot is untouched")+        #expect(+            state.rule(in: parsed).definition+                == .workAndSequence(+                    work: URLFieldSelector(locator: workLocator),+                    sequence: URLFieldSelector(+                        locator: .pathBracketed(+                            left: Self.literal("14545097"), right: .unanchored))))+    }++    /// Req 1.9's lifecycle table, the reflected half: a stored anchoring the+    /// controls do show survives a re-tap of the chip it is shown on, exactly as+    /// an explicit choice does. `reAnchorRetainsTemplate` covers this through the+    /// republished definition; this pins the anchoring state the controls read.+    @Test("A reflected stored anchoring survives a re-tap of the same chip")+    func reflectedAnchoringSurvivesASameChipTap() throws {+        let parsed = try Self.components(Self.tthURL)+        let storedRight = Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm")+        var state = State()+        state.seed(from: Self.tthStoredDefinition, in: parsed)+        #expect(state.workAnchoring.left == .start)+        #expect(state.workAnchoring.right == storedRight)++        #expect(state.selectComponent(.path(0), in: parsed) == nil)+        #expect(state.workAnchoring.left == .start)+        #expect(state.workAnchoring.right == storedRight)+        #expect(!state.workAnchoring.edited, "a re-tap is not an edit")+        #expect(state.rule(in: parsed).definition == Self.tthStoredDefinition)+    }+     // MARK: - The gesture path publishes the same locator      @Test("A chip tap through the editor state publishes the corrected locator")@@ -169,10 +1238,15 @@ struct ComposedURLEditorStateTests {             template: tthTemplate)     } -    /// The whole point of Req 3.7. Re-selecting the same component is the only-    /// gesture that applies the corrected locator, and before this it was also the-    /// gesture that dropped the template — moving all 40 captures from version-2-    /// keys to conservative ones.+    /// The whole point of Req 3.7. Re-anchoring the component is the only+    /// gesture that applies the corrected locator, and before that spec it was+    /// also the gesture that dropped the template — moving all 40 captures from+    /// version-2 keys to conservative ones.+    ///+    /// The gesture itself has moved: since Req 1.9 the seeded anchoring is+    /// reflected into the controls and survives a chip re-tap, so the re-anchor+    /// is the anchoring choice rather than the tap. The template must survive+    /// both.     @Test("Re-anchoring the same component keeps the stored template")     func reAnchorRetainsTemplate() throws {         let parsed = try Self.components(Self.tthURL)@@ -181,11 +1255,15 @@ struct ComposedURLEditorStateTests {         #expect(state.work == .path(0))         #expect(state.retainedTemplate == Self.tthTemplate) -        // The reader taps the same chip that is already selected.+        // The reader taps the same chip that is already selected: the stored+        // anchoring stands, so the stored rule is republished unchanged.         state.select(.path(0))-        let outcome = state.rule(in: parsed)+        #expect(state.rule(in: parsed).definition == Self.tthStoredDefinition)++        // …and then unpins the right side, which is the repair.+        #expect(state.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: parsed) == nil)         #expect(-            outcome.definition+            state.rule(in: parsed).definition                 == .combined(                     locator: .pathBracketed(left: .start, right: .unanchored),                     template: Self.tthTemplate))@@ -199,6 +1277,7 @@ struct ComposedURLEditorStateTests {         var state = State()         state.seed(from: Self.tthStoredDefinition, in: parsed)         state.select(.path(0))+        #expect(state.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: parsed) == nil)         let republished = try #require(state.rule(in: parsed).definition)         #expect(republished != Self.tthStoredDefinition)         #expect(!RuleDefinitionComparator.semanticallyEqual(republished, Self.tthStoredDefinition))@@ -222,11 +1301,21 @@ struct ComposedURLEditorStateTests {         // component.         state.select(.path(1))         #expect(state.retainedTemplate == nil)-        #expect(-            state.rule(in: parsed).definition-                == .work(locator: .pathBracketed(left: Self.literal("Story-1-2"), right: .unanchored)))+        // The repeated value is also why no locator is authored outright: both+        // `.literal("Story-1-2")` and the unanchored left bracket index 1 *and*+        // index 2, so the two anchorings that do resolve there mean different+        // things and the reader decides (Decision 5, outcome 3). The status is+        // what says so — a nil definition alone would equally describe a clear.+        let outcome = state.rule(in: parsed)+        guard case .pending = outcome.status else {+            Issue.record("expected a pending status on the repeated value")+            return+        }+        #expect(outcome.definition == nil)     } +    /// The split gestures are orthogonal to anchoring (Q11), so the stored+    /// anchoring stands while the template goes.     @Test("Use the whole component instead clears the template")     func useWholeComponentClearsTemplate() throws {         let parsed = try Self.components(Self.tthURL)@@ -236,7 +1325,10 @@ struct ComposedURLEditorStateTests {         #expect(state.retainedTemplate == nil)         #expect(             state.rule(in: parsed).definition-                == .work(locator: .pathBracketed(left: .start, right: .unanchored)))+                == .work(+                    locator: .pathBracketed(+                        left: .start,+                        right: Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))))     }      @Test("Selecting a separate sequence component clears the template")@@ -259,7 +1351,11 @@ struct ComposedURLEditorStateTests {         state.seed(from: Self.tthStoredDefinition, in: parsed)         state.clear()         #expect(state.retainedTemplate == nil)-        #expect(state.rule(in: parsed).definition == nil)+        // Cleared, not pending: an empty slot authors nothing and gates nothing+        // (Q22 — the two are indistinguishable through the definition alone).+        let outcome = state.rule(in: parsed)+        #expect(outcome.status == .cleared)+        #expect(outcome.definition == nil)     }      @Test("Re-seeding from a non-combined rule drops any retained template")@@ -392,7 +1488,9 @@ struct ComposedURLEditorStateTests {         #expect(             state.rule(in: parsed).definition                 == .combined(-                    locator: .pathBracketed(left: .start, right: .unanchored),+                    locator: .pathBracketed(+                        left: .start,+                        right: Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm")),                     template: Self.tthOptionalTemplate))     } @@ -401,29 +1499,27 @@ struct ComposedURLEditorStateTests {     @Test("The retained-template branch emits the state's presence")     func retainedBranchCarriesPresence() throws {         let parsed = try Self.components(Self.tthURL)+        let storedLocator = URLComponentLocator.pathBracketed(+            left: .start,+            right: Self.literal("DianeCastle+The+Secret+Return+of+Alex+Mack.htm"))         var state = State()         state.seed(from: Self.tthStoredDefinition, in: parsed)         state.setSequencePresence(.optional)-        // The locator is rebuilt from the selection, as every dispatch does; only-        // the template is retained.+        // The locator is re-derived from the slot on every dispatch — here the+        // seeded anchoring, which the toggle does not touch; only the template is+        // retained.         #expect(             state.rule(in: parsed).definition-                == .combined(-                    locator: .pathBracketed(left: .start, right: .unanchored),-                    template: Self.tthOptionalTemplate))+                == .combined(locator: storedLocator, template: Self.tthOptionalTemplate))         // And back off again: `.required` has one representation, so the template         // is byte-for-byte the stored one (Req 2.3).         #expect(             state.rule(in: parsed).definition-                != .combined(-                    locator: .pathBracketed(left: .start, right: .unanchored),-                    template: Self.tthTemplate))+                != .combined(locator: storedLocator, template: Self.tthTemplate))         state.setSequencePresence(.required)         #expect(             state.rule(in: parsed).definition-                == .combined(-                    locator: .pathBracketed(left: .start, right: .unanchored),-                    template: Self.tthTemplate))+                == .combined(locator: storedLocator, template: Self.tthTemplate))     }      /// The live-split branch re-derives on every dispatch, which is exactly why a@@ -442,9 +1538,15 @@ struct ComposedURLEditorStateTests {             template: Self.tthOptionalTemplate))          // A token tap re-derives the template; the declaration must not be lost.+        // The tap has to *change* the split for that to mean anything, so the+        // widened span is asserted before it is taken back.         let tokens = ComposedTeachingPresentation.tokenRanges(in: text)+        let before = try #require(state.split)         state.toggleSplitToken(at: tokens.count - 1, tokens: tokens)+        #expect(state.split != before, "the tap widened the Work span")+        _ = state.rule(in: parsed)         state.toggleSplitToken(at: tokens.count - 1, tokens: tokens)+        #expect(state.split == before, "and the second tap took it back")         guard case .combined(_, let template)? = state.rule(in: parsed).definition else {             Issue.record("expected a combined rule after the token taps")             return@@ -453,18 +1555,22 @@ struct ComposedURLEditorStateTests {     }      /// The toggle must never narrow the rule to `.work` — that is the downgrade-    /// which moved 40 captures off their version-2 keys.+    /// which moved 40 captures off their version-2 keys. Toggled repeatedly on+    /// **one** state, because the write-backs `rule(in:)` performs are what a+    /// second toggle would meet; re-seeding between toggles would only ever+    /// re-run the first one.     @Test("No path from the toggle publishes a work-only rule")     func toggleNeverPublishesWorkOnly() throws {         let parsed = try Self.components(Self.tthURL)+        var state = State()+        state.seed(from: Self.tthStoredDefinition, in: parsed)         for presence in [URLSequencePresence.optional, .required, .optional] {-            var state = State()-            state.seed(from: Self.tthStoredDefinition, in: parsed)             state.setSequencePresence(presence)-            guard case .combined? = state.rule(in: parsed).definition else {+            guard case .combined(_, let template)? = state.rule(in: parsed).definition else {                 Issue.record("presence \(presence) published a non-combined rule")                 return             }+            #expect(template.sequencePresence == presence)         }     } @@ -472,8 +1578,9 @@ struct ComposedURLEditorStateTests {      @Test("The gate refuses a template with neither prefix nor suffix")     func gateRefusesUnboundedTemplate() throws {-        // `/28614/28614-105/`: splitting the second component authors an-        // unaffixed template, which `validate` refuses under `.optional`.+        // `/28614-105/tail`: splitting the first component authors a template+        // with neither a prefix nor a suffix, which `validate` refuses under+        // `.optional`.         let parsed = try Self.components("https://example.test/28614-105/tail")         var state = State()         state.select(.path(0))
Asterism/AsterismTests/ComposedTeachingViewModelTests.swift Modified +493 / −7
diff --git a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swiftindex 49e9536..6ba5f53 100644--- a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift+++ b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift@@ -139,6 +139,254 @@ struct ComposedTeachingViewModelTests {         #expect(vm.urlSelectionSummary == nil)     } +    // MARK: - URL rule status plumbing (Reqs 1.6, 1.8, Q22)++    private typealias URLOutcome = ComposedTeachingPresentation.URLRuleOutcome++    private static func pendingOutcome(+        slot: ComposedTeachingPresentation.URLSlot = .work,+        missing: Set<ComposedTeachingPresentation.AnchorSide> = [.right]+    ) -> URLOutcome {+        URLOutcome(+            status: .pending(+                message: ComposedTeachingPresentation.anchoringPendingNotice(+                    slot: slot, missing: missing)))+    }++    private static let queryLocator = URLComponentLocator.query(name: ExactScalarString("chapter"))++    @Test("A half-chosen anchoring blocks the commit, names the missing side, and previews nothing")+    func pendingAnchoringBlocksConfirm() async throws {+        let (vm, mock) = makeSUT(contract: try Self.chapterBearingContract())+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        #expect(vm.canConfirm)+        let projections = mock.projectComposedTeachingCallCount++        vm.updateURLRule(Self.pendingOutcome())+        try await Task.sleep(for: .milliseconds(50))++        #expect(!vm.canConfirm)+        let message = try #require(vm.urlAnchoringPendingMessage)+        #expect(message.contains("after it"))+        #expect(message.contains(ComposedTeachingPresentation.workSlotLabel))+        #expect(vm.urlRuleDefinition == nil, "a half-authored rule is no definition")+        // The preview is not generated from a rule the reader has not finished+        // (design: `generatePreviewIfValid` returns early unless valid or cleared).+        #expect(mock.projectComposedTeachingCallCount == projections)+        // The explanation lives inside the disclosure as well as beside confirm,+        // so the disclosure is held open while it stands.+        #expect(vm.urlDisclosureExpanded)+    }++    /// The disclosure's binding has one authority (Req 1.8). While the URL side+    /// blocks the commit the section is held open, so a collapse tap that changes+    /// nothing on screen must record nothing either: recording it left the model+    /// collapsed behind a section that still showed, and the section snapped shut+    /// the moment the reader completed the anchoring pair.+    @Test("A collapse is ignored while the anchoring is half-chosen, and the section survives the pair completing")+    func collapseIsIgnoredWhileTheAnchoringPends() async throws {+        let (vm, _) = makeSUT(contract: try Self.chapterBearingContract())+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        vm.expandDisclosure()+        #expect(vm.disclosureState == .expanded)++        vm.updateURLRule(Self.pendingOutcome())+        #expect(vm.urlDisclosureExpanded, "the controls that release the commit are inside")++        // The reader taps the chevron: nothing visible changes, so nothing is+        // recorded either.+        vm.setURLDisclosureExpanded(false)+        #expect(vm.urlDisclosureExpanded)+        #expect(vm.disclosureState == .expanded)++        // Completing the pair must not snap the section shut.+        vm.updateURLRule(URLOutcome(status: .valid(.sequence(locator: Self.queryLocator))))+        try await Task.sleep(for: .milliseconds(50))+        #expect(vm.urlAnchoringPendingMessage == nil)+        #expect(vm.urlDisclosureExpanded)+        #expect(vm.disclosureState == .expanded)++        // And a collapse now lands: the gate is about the pending state, not+        // about collapsing.+        vm.setURLDisclosureExpanded(false)+        #expect(!vm.urlDisclosureExpanded)+        #expect(vm.disclosureState == .collapsed)+    }++    /// The other half of the same bug: the ignored collapse must not record a+    /// chapter-remedy reading either, since `chapterUnsourced` is force-suppressed+    /// while the URL rule is unsettled (Q22).+    @Test("The chapter remedy still governs the disclosure after a pending detour")+    func chapterRemedySurvivesAPendingDetour() async throws {+        // The default whole-title selection sources no chapter, so the remedy+        // trigger owns the disclosure.+        let (vm, _) = makeSUT()+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        #expect(vm.chapterUnsourced)+        #expect(vm.urlDisclosureExpanded, "auto-expanded as the remedy")++        // A deliberate collapse, on a settled (cleared) URL side, is honoured.+        vm.setURLDisclosureExpanded(false)+        #expect(!vm.urlDisclosureExpanded)++        vm.updateURLRule(Self.pendingOutcome())+        #expect(!vm.chapterUnsourced, "an unfinished URL rule is not a cleared one")+        #expect(vm.urlDisclosureExpanded)+        vm.setURLDisclosureExpanded(false)+        #expect(vm.urlDisclosureExpanded, "held open while the commit is blocked")++        // Back to a settled, chapter-less URL side: the selection left and+        // re-entered the unsourced state, so the remedy opens the section again.+        vm.updateURLRule(URLOutcome(status: .cleared))+        try await Task.sleep(for: .milliseconds(50))+        #expect(vm.chapterUnsourced)+        #expect(vm.urlDisclosureExpanded)+        #expect(vm.disclosureState == .expanded)+    }++    @Test("An unauthorable selection blocks the commit with its own explanation")+    func unauthorableSelectionBlocksConfirm() async throws {+        let (vm, mock) = makeSUT(contract: try Self.chapterBearingContract())+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        let projections = mock.projectComposedTeachingCallCount++        vm.updateURLRule(+            URLOutcome(+                status: .unauthorable(+                    message: ComposedTeachingPresentation.componentUnauthorableNotice)))+        try await Task.sleep(for: .milliseconds(50))++        #expect(!vm.canConfirm)+        #expect(+            vm.urlAnchoringPendingMessage+                == ComposedTeachingPresentation.componentUnauthorableNotice)+        #expect(mock.projectComposedTeachingCallCount == projections)+    }++    @Test("Pending is distinguishable from cleared downstream")+    func pendingIsDistinguishableFromCleared() async throws {+        // The default whole-title selection sources no chapter, so a genuinely+        // cleared URL rule leaves the chapter unsourced.+        let (vm, _) = makeSUT()+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        #expect(vm.chapterUnsourced)+        #expect(vm.urlSelectionSummary == nil)++        vm.updateURLRule(Self.pendingOutcome())+        #expect(!vm.chapterUnsourced, "an unfinished URL rule is not a cleared one")+        #expect(vm.urlSelectionSummary == vm.urlAnchoringPendingMessage)++        vm.updateURLRule(URLOutcome(status: .cleared))+        #expect(vm.chapterUnsourced)+        #expect(vm.urlSelectionSummary == nil)+        #expect(vm.urlAnchoringPendingMessage == nil)+    }++    /// A stored rule that declares the chapter part optional, so the Req 2.9+    /// removal warning has something to warn about.+    private static func optionalSequenceBasis() -> ComposedURLRuleBasis {+        ComposedURLRuleBasis(+            id: UUID(), version: 1, origin: .readerTaught,+            definition: .combined(+                locator: .pathBracketed(left: .start, right: .unanchored),+                template: URLTwoFieldTemplate(+                    prefix: ExactScalarString("Story-"), separator: ExactScalarString("-"),+                    suffix: ExactScalarString(""), order: .workThenSequence,+                    sequencePresence: .optional)))+    }++    /// Req 2.9's warning is about what a commit would remove, and an unsettled+    /// status commits nothing. The Req 1.7 flow reaches this state on the first+    /// side chosen, so reading pending as cleared warns mid-gesture.+    @Test("An unsettled URL rule removes no optional-sequence declaration")+    func pendingDoesNotWarnAboutRemoval() async throws {+        let (vm, _) = makeSUT(contract: Self.makeContract(currentURLRule: Self.optionalSequenceBasis()))+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        #expect(vm.sequencePresenceRemovalMessage == nil, "the declaration is still in force")++        vm.updateURLRule(Self.pendingOutcome())+        #expect(vm.sequencePresenceRemovalMessage == nil, "a half-chosen anchoring removes nothing")++        vm.updateURLRule(+            URLOutcome(+                status: .unauthorable(+                    message: ComposedTeachingPresentation.componentUnauthorableNotice)))+        #expect(vm.sequencePresenceRemovalMessage == nil)++        // A rule that genuinely settles without the declaration is the removal.+        vm.updateURLRule(URLOutcome(status: .valid(.sequence(locator: Self.queryLocator))))+        #expect(+            vm.sequencePresenceRemovalMessage+                == ComposedTeachingViewModel.sequencePresenceRemovalWarning)+    }++    /// Req 1.8's gate holds on the paths that reach the projection without+    /// passing `canConfirm`.+    @Test("The acknowledgment path projects and commits nothing while the anchoring is pending")+    func pendingAnchoringBlocksTheAcknowledgmentPath() async throws {+        let (vm, mock) = makeSUT(contract: Self.makeContract(requiresAck: true))+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        let projections = mock.projectComposedTeachingCallCount++        vm.updateURLRule(Self.pendingOutcome())+        try await Task.sleep(for: .milliseconds(50))+        await vm.acknowledgeAndConfirm()++        #expect(mock.projectComposedTeachingCallCount == projections)+        #expect(mock.commitComposedTeachingCallCount == 0)+        #expect(vm.state != .committed)+    }++    @Test("A valid outcome publishes its definition, previews, and clears the commit gate")+    func validOutcomePreviews() async throws {+        let (vm, mock) = makeSUT(contract: try Self.chapterBearingContract())+        await vm.load()+        try await Task.sleep(for: .milliseconds(50))+        let projections = mock.projectComposedTeachingCallCount++        vm.updateURLRule(URLOutcome(status: .valid(.sequence(locator: Self.queryLocator))))+        try await Task.sleep(for: .milliseconds(50))++        #expect(vm.urlRuleDefinition == .sequence(locator: Self.queryLocator))+        #expect(vm.urlAnchoringPendingMessage == nil)+        #expect(mock.projectComposedTeachingCallCount > projections)+        #expect(vm.canConfirm)+        #expect(mock.lastProjectComposedRequest?.urlDefinition == .sequence(locator: Self.queryLocator))+    }++    @Test("load() initialises the URL rule status alongside the definition")+    func loadInitialisesURLRuleStatus() async throws {+        let stored = Self.urlRuleBasis()+        let (taught, _) = makeSUT(contract: Self.makeContract(currentURLRule: stored))+        await taught.load()+        #expect(taught.urlRuleStatus == .valid(stored.definition))+        #expect(taught.urlRuleDefinition == stored.definition)++        let (untaught, _) = makeSUT()+        await untaught.load()+        #expect(untaught.urlRuleStatus == .cleared)+        #expect(untaught.urlRuleDefinition == nil)+    }++    @Test("The definition shim publishes the equivalent status")+    func definitionShimPublishesStatus() async throws {+        let (vm, _) = makeSUT(contract: try Self.chapterBearingContract())+        await vm.load()++        vm.setURLRuleDefinition(.sequence(locator: Self.queryLocator))+        #expect(vm.urlRuleStatus == .valid(.sequence(locator: Self.queryLocator)))++        vm.setURLRuleDefinition(nil)+        #expect(vm.urlRuleStatus == .cleared)+    }+     // MARK: - Acknowledgment flow (Req 2.1, Q3)      @Test("Confirm surfaces the acknowledgment interstitial when chapters stay unsettled")@@ -281,8 +529,8 @@ struct ComposedTeachingViewModelTests {         #expect(vm.trimPrefix == nil)     } -    @Test("Subdivided parts with Work only author a whole-title rule plus trims")-    func subdividedPartsWorkOnlyAuthorWholeTitleWithTrims() async throws {+    @Test("A fully-kept subdivided segment reclassifies; dropping a part authors whole-title plus trims")+    func fullyKeptSegmentReclassifiesThenDroppedPartAuthorsWholeTitleWithTrims() async throws {         let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))         await vm.load() @@ -295,17 +543,26 @@ struct ComposedTeachingViewModelTests {          #expect(vm.isSubdivided)         #expect(vm.titleChips.count == 4)-        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)-        #expect(vm.trimPrefix == "TtH - Story - ")+        // Req 3.1 / Q18, a deliberate change (Req 4.2's exception): every part+        // of the segment is kept, so the marking counts as a whole-segment+        // marking and authors the sturdier positional form instead of+        // whole-title-plus-trims. The extracted value is unchanged.+        #expect(vm.effectiveTitleRule?.definition == .chapterlessSegment(+            work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []))+        #expect(vm.trimPrefix == nil)         #expect(vm.trimSuffix == nil)         #expect(vm.workNamePreview == "Real Title") -        // Dropping the trailing part narrows the kept span and the trims follow.-        // Chapter is skipped here: it would need the blank separator `.phrase`-        // rejects, so the cycle lands straight on ignore (Req 8.6).+        // Dropping the trailing part makes the marking a proper subset again;+        // with no other segment marked, Decision 3's trigger stays shut and the+        // whole-title-plus-trims form stands. Chapter is skipped here: it would+        // need the blank separator `.phrase` rejects, so the cycle lands+        // straight on ignore (Req 8.6).         vm.cycleTitleRole(at: 3)         #expect(vm.titleRoles == [.ignore, .ignore, .work, .ignore])+        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)         #expect(vm.workNamePreview == "Real")+        #expect(vm.trimPrefix == "TtH - Story - ")         #expect(vm.trimSuffix == " Title")     } @@ -509,6 +766,235 @@ struct ComposedTeachingViewModelTests {         #expect(request.trimPrefix == "TtH - ")     } +    // MARK: - Seeding a segment rule that carries trims (Req 3.8)++    /// The tapas shape: `.segment` over the trimmed title with the prefix trim+    /// that drops the site's `Read ` boilerplate.+    private static func trimmedSegmentRule() throws -> ComposedTitleRuleBasis {+        titleRuleBasis(+            definition: .segment(+                work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+                ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]),+            trimPrefix: "Read ")+    }++    @Test("Re-teaching a trimmed segment rule seeds the markings and the trims")+    func reteachSeedsTrimmedSegmentSelection() async throws {+        let stored = try Self.trimmedSegmentRule()+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Read Story :: Episode 12 | Tapas Comics"))+        await vm.load()++        // The first segment is subdivided so the trim boundary is expressible,+        // and the kept run carries the Work role.+        #expect(vm.subdividedSegments == [0])+        #expect(vm.titleRoles == [.ignore, .work, .chapter, .ignore])+        #expect(vm.storedTitleRuleNotice == nil)+        #expect(vm.effectiveTitleRule?.definition == stored.definition)+        #expect(vm.trimPrefix == "Read ")+        #expect(vm.trimSuffix == nil)+        #expect(vm.workNamePreview == "Story")+        #expect(vm.chapterNamePreview == "Episode 12")+    }++    /// The mirror of `trimmedSegmentRule`: the boilerplate is a **suffix**, the+    /// Work is the last segment, and the chapter is what remains. Every other+    /// seeding fixture trims a prefix, so the trailing half of+    /// `trimmedSegmentSelection` went unexercised.+    @Test("Re-teaching a suffix-trimmed segment rule seeds the markings and the trim")+    func reteachSeedsSuffixTrimmedSegmentSelection() async throws {+        let stored = Self.titleRuleBasis(+            definition: .segment(+                work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []),+            trimSuffix: " Online")+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Episode 12 :: The Regressor Online"))+        await vm.load()++        // The last segment is subdivided so the trim boundary is expressible,+        // and the kept run carries the Work role.+        #expect(vm.subdividedSegments == [1])+        #expect(vm.titleRoles == [.chapter, .work, .work, .ignore])+        #expect(vm.storedTitleRuleNotice == nil)+        #expect(vm.effectiveTitleRule?.definition == stored.definition)+        #expect(vm.trimPrefix == nil)+        #expect(vm.trimSuffix == " Online")+        #expect(vm.workNamePreview == "The Regressor")+        #expect(vm.chapterNamePreview == "Episode 12")+    }++    /// The chapter-less arm of the same seeding path: the remainder is ignored+    /// rather than read as the chapter, and the Work spans two segments so the+    /// edge-trim trigger has the whole-segment marking it needs.+    @Test("Re-teaching a trimmed chapter-less segment rule seeds its markings")+    func reteachSeedsTrimmedChapterlessSegmentSelection() async throws {+        let stored = Self.titleRuleBasis(+            definition: .chapterlessSegment(+                work: try SegmentRangeSpec(origin: .start, offset: 0, length: 2), ignored: []),+            trimPrefix: "Read ")+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Read The Regressor :: Book Two | Tapas Comics"))+        await vm.load()++        #expect(vm.subdividedSegments == [0])+        #expect(vm.titleRoles == [.ignore, .work, .work, .work, .ignore])+        #expect(vm.storedTitleRuleNotice == nil)+        #expect(vm.effectiveTitleRule?.definition == stored.definition)+        #expect(vm.trimPrefix == "Read ")+        // Chapter-less: nothing in the title sources a chapter, which is what+        // opens the URL details as the remedy.+        #expect(vm.chapterNamePreview == nil)+        #expect(vm.chapterUnsourced)+    }++    @Test("A trim absent from this title leaves the default selection and says the stored rule is in effect")+    func trimAbsentFromTitleKeepsDefaultSelectionAndNotice() async throws {+        let stored = try Self.trimmedSegmentRule()+        let (vm, mock) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Watch Story :: Episode 12 | Tapas Comics"))+        await vm.load()+        try await Task.sleep(for: .milliseconds(30))++        #expect(vm.subdividedSegments.isEmpty)+        #expect(vm.titleRoles == [.work, .work, .work])+        #expect(vm.storedTitleRuleNotice == ComposedTeachingPresentation.storedTitleRuleNotice)+        // The stored rule stays in effect and recommits verbatim.+        #expect(vm.effectiveTitleRule?.definition == stored.definition)+        #expect(vm.trimPrefix == "Read ")+        vm.setURLRuleDefinition(.sequence(locator: .query(name: ExactScalarString("chapter"))))+        try await Task.sleep(for: .milliseconds(30))+        let request = try #require(mock.lastProjectComposedRequest)+        #expect(request.titleDefinition == stored.definition)+        #expect(request.trimPrefix == "Read ")+    }++    @Test("A trim boundary that is not part-aligned leaves the default selection and the notice")+    func unalignedTrimBoundaryKeepsDefaultSelectionAndNotice() async throws {+        // "Rea" cuts inside the first part, a boundary no chip selection can+        // express, so the editor must not depict a different rule as the stored+        // one.+        let stored = Self.titleRuleBasis(+            definition: .segment(+                work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+                ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]),+            trimPrefix: "Rea")+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Read Story :: Episode 12 | Tapas Comics"))+        await vm.load()++        #expect(vm.subdividedSegments.isEmpty)+        #expect(vm.titleRoles == [.work, .work, .work])+        #expect(vm.storedTitleRuleNotice == ComposedTeachingPresentation.storedTitleRuleNotice)+        #expect(vm.effectiveTitleRule?.trimPrefix == "Rea")+    }++    @Test("A seeded trimmed rule recommits verbatim when the reader does not edit the title")+    func seededTrimmedRuleRecommitsVerbatim() async throws {+        let stored = try Self.trimmedSegmentRule()+        let (vm, mock) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Read Story :: Episode 12 | Tapas Comics"))+        await vm.load()+        try await Task.sleep(for: .milliseconds(30))++        vm.setURLRuleDefinition(.sequence(locator: .query(name: ExactScalarString("chapter"))))+        try await Task.sleep(for: .milliseconds(30))++        let request = try #require(mock.lastProjectComposedRequest)+        #expect(request.titleDefinition == stored.definition)+        #expect(request.trimPrefix == "Read ")+        #expect(request.trimSuffix == nil)+    }++    @Test("Editing the title clears the stored-rule notice")+    func titleEditClearsStoredRuleNotice() async throws {+        let stored = try Self.trimmedSegmentRule()+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Watch Story :: Episode 12 | Tapas Comics"))+        await vm.load()+        #expect(vm.storedTitleRuleNotice != nil)++        vm.cycleTitleRole(at: 0)++        #expect(vm.storedTitleRuleNotice == nil)+    }++    // MARK: - Trim caption (Req 3.7, Q24)++    @Test("The trim caption names the dropped text for a segment-form rule")+    func trimCaptionForSegmentFormRule() async throws {+        let stored = try Self.trimmedSegmentRule()+        let (vm, _) = makeSUT(+            contract: Self.makeContract(currentTitleRule: stored),+            entry: Self.makeEntry(title: "Read Story :: Episode 12 | Tapas Comics"))+        await vm.load()++        #expect(vm.titleTrimCaption == "Dropping “Read ” from the front.")+    }++    @Test("The trim caption covers whole-title trims and both edges")+    func trimCaptionForWholeTitleTrims() async throws {+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: "TtH - Story - Real Title"))+        await vm.load()+        #expect(vm.titleTrimCaption == nil)++        // Whole-title plus trims: the pre-existing form whose trims had no UI+        // consumer until now (Q24).+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 0)+        vm.cycleTitleRole(at: 1)+        vm.cycleTitleRole(at: 1)+        vm.subdivideSegment(atChip: 2)+        vm.cycleTitleRole(at: 3)++        #expect(vm.effectiveTitleRule?.definition == .wholeTitle)+        #expect(vm.titleTrimCaption == "Dropping “TtH - Story - ” from the front and “ Title” from the end.")+    }++    // MARK: - One rule for both tapas title families (Reqs 3.6, 3.9)++    @Test("The scissors gesture authors one rule that parses both tapas title families")+    func tapasFamiliesShareOneAuthoredRule() async throws {+        let comics = "Read Story ::  Episode 12 | Tapas Comics"+        let novels = "Read Story :: Chapter 5 | Tapas Novels"+        let (vm, _) = makeSUT(entry: Self.makeEntry(title: comics))+        await vm.load()+        #expect(vm.titleChips.count == 3)++        // Drive the real gestures (Req 3.9): the site tail leaves the Work, the+        // episode segment becomes the chapter, then the first segment is+        // subdivided and its boilerplate word discarded.+        vm.cycleTitleRole(at: 2)+        vm.cycleTitleRole(at: 2)+        #expect(vm.titleRoles == [.work, .work, .ignore])+        vm.cycleTitleRole(at: 1)+        #expect(vm.titleRoles == [.work, .chapter, .ignore])+        vm.subdivideSegment(atChip: 0)+        #expect(vm.titleChips.count == 4)+        vm.cycleTitleRole(at: 0)++        #expect(vm.titleRoles == [.ignore, .work, .chapter, .ignore])+        let rule = try #require(vm.effectiveTitleRule)+        #expect(rule.trimPrefix == "Read ")+        #expect(rule.trimSuffix == nil)+        #expect(vm.workNamePreview == "Story")+        #expect(vm.chapterNamePreview == "Episode 12")++        // Req 3.6: the one rule parses the other family too — same Work, its own+        // chapter — which the phrase form this replaces could never do.+        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix,+            trimSuffix: rule.trimSuffix, to: novels).get())+        #expect(parsed.workName == "Story")+        #expect(parsed.chapterTitle == "Chapter 5")+    }+     @Test("Editing the title after load authors a fresh rule rather than the retained one")     func titleEditOverridesRetainedRule() async throws {         let retained = try SegmentRangeSpec(origin: .end, offset: 0, length: 1)
Asterism/AsterismTests/ComposedTitleTrimInferenceTests.swift Added +425 / −0
diff --git a/Asterism/AsterismTests/ComposedTitleTrimInferenceTests.swift b/Asterism/AsterismTests/ComposedTitleTrimInferenceTests.swiftnew file mode 100644index 0000000..4742af5--- /dev/null+++ b/Asterism/AsterismTests/ComposedTitleTrimInferenceTests.swift@@ -0,0 +1,425 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// The title inference pipeline as widened by Reqs 3.1–3.5: the fully-kept+/// reclassification (Req 3.1, Q18), the edge-trim trigger (Req 3.2, Decision 3),+/// today's inference everywhere else (Req 3.3), post-trim anchor derivation+/// (Req 3.4, Q10), and exact per-edge trim text (Req 3.5, Q19).+@Suite("ComposedTitleTrimInference")+@MainActor+struct ComposedTitleTrimInferenceTests {++    // MARK: - Fixtures++    /// One marking state: the example title's segments, the chip row for the+    /// subdivided segments, and one role per chip.+    private struct Marking {+        let title: String+        let segments: [ComposedTeachingPresentation.TitleSegment]+        let chips: [ComposedTeachingPresentation.TitleChip]+        let roles: [SegmentRole]++        var outcome: (branch: ComposedTeachingPresentation.TitleInferenceBranch,+                      rule: ComposedTeachingPresentation.InferredTitleRule?) {+            ComposedTeachingPresentation.inferenceOutcome(+                title: title, segments: segments, chips: chips, roles: roles)+        }++        var rule: ComposedTeachingPresentation.InferredTitleRule? { outcome.rule }+        var branch: ComposedTeachingPresentation.TitleInferenceBranch { outcome.branch }+    }++    private static func marking(+        _ title: String, subdividing: Set<Int> = [], roles: [SegmentRole]+    ) -> Marking {+        let segments = ComposedTeachingPresentation.titleSegments(in: title)+        let chips = ComposedTeachingPresentation.titleChips(segments: segments, subdividing: subdividing)+        return Marking(title: title, segments: segments, chips: chips, roles: roles)+    }++    /// Every role assignment over `count` chips, in a stable order.+    private static func roleAssignments(count: Int) -> [[SegmentRole]] {+        let order: [SegmentRole] = [.work, .chapter, .ignore]+        var result: [[SegmentRole]] = [[]]+        for _ in 0..<count {+            result = result.flatMap { partial in order.map { partial + [$0] } }+        }+        return result+    }++    // MARK: - Reclassification (Req 3.1, Q18)++    @Test("A fully-kept subdivided segment counts as a whole-segment marking, punctuation included")+    func fullyKeptSubdividedSegmentCountsAsWholeSegment() async throws {+        // Segments: "Read Story", "(Book 1)", "Site". Subdividing the middle+        // segment yields the parts "Book" and "1"; keeping both as Work is a+        // whole-segment marking whose value keeps the brackets (Q18).+        let state = Self.marking(+            "Read Story :: (Book 1) | Site", subdividing: [1],+            roles: [.ignore, .work, .work, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .segmentForm)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == nil)+        let expected = PatternDefinition.chapterlessSegment(+            work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1), ignored: [])+        #expect(rule.definition == expected)++        // The deliberate behaviour change (Q18, Req 4.2's exception): the field+        // value is the whole segment, brackets and all.+        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: state.title).get())+        #expect(parsed.workName == "(Book 1)")+    }++    @Test("A fully-kept edge segment is not an edge part-marking: no trim derives")+    func fullyKeptEdgeSegmentIsNotAnEdgePartMarking() async throws {+        // Both parts of the first segment kept — reclassified to one whole+        // segment (Req 3.1), so the edge-trim trigger cannot see a proper+        // subset and the positional form carries no trims.+        let state = Self.marking(+            "Read Story :: Episode 12 | Site", subdividing: [0],+            roles: [.work, .work, .chapter, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .segmentForm)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == nil)+    }++    // MARK: - The edge-trim trigger (Req 3.2)++    @Test("The tapas shape derives the prefix trim and a positional segment rule")+    func tapasShapeDerivesPrefixTrim() async throws {+        let title = "Read Story ::  Episode 12 | Tapas Comics"+        let state = Self.marking(title, subdividing: [0], roles: [.ignore, .work, .chapter, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .edgeTrim)+        #expect(rule.trimPrefix == "Read ")+        // The last segment carries no kept chip, so no suffix trim derives+        // (Req 3.5).+        #expect(rule.trimSuffix == nil)+        let expected = PatternDefinition.segment(+            work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+            ignored: [try SegmentPositionSpec(origin: .end, offset: 0)])+        #expect(rule.definition == expected)++        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: title).get())+        #expect(parsed.workName == "Story")+        #expect(parsed.chapterTitle == "Episode 12")+    }++    @Test("A suffix-only run with an ignored first segment derives no prefix trim")+    func suffixOnlyRunDerivesNoPrefixTrim() async throws {+        // Segments: "TtH", "Story", "Real Title". The last segment's leading+        // run is kept; the first segment carries no kept chip at all.+        let title = "TtH - Story - Real Title"+        let state = Self.marking(title, subdividing: [2], roles: [.chapter, .ignore, .work, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .edgeTrim)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == " Title")++        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: title).get())+        #expect(parsed.workName == "Real")+        #expect(parsed.chapterTitle == "TtH")+    }++    @Test("Runs on both edges derive both trims")+    func bothEdgesDeriveTrims() async throws {+        // Segments: "Read Story", "Episode 12", "Tapas Comics". A run on each+        // edge, the interior segment marked whole.+        let title = "Read Story :: Episode 12 | Tapas Comics"+        let state = Self.marking(+            title, subdividing: [0, 2], roles: [.ignore, .chapter, .work, .work, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .edgeTrim)+        #expect(rule.trimPrefix == "Read ")+        #expect(rule.trimSuffix == " Comics")++        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: title).get())+        #expect(parsed.workName == "Episode 12 | Tapas")+        #expect(parsed.chapterTitle == "Story")+    }++    @Test("A run on one edge leaves the other edge untrimmed")+    func aRunOnOneEdgeLeavesTheOtherUntrimmed() async throws {+        // The last segment is subdivided but carries no kept chip, so no suffix+        // trim derives (Req 3.5) — the segment stays a positional ignore.+        let title = "Read Story :: Episode 12 | Tapas Comics"+        let state = Self.marking(+            title, subdividing: [0, 2], roles: [.ignore, .work, .chapter, .ignore, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .edgeTrim)+        #expect(rule.trimPrefix == "Read ")+        #expect(rule.trimSuffix == nil)+    }++    @Test("An edge run may carry the chapter role")+    func chapterRoleEdgeRun() async throws {+        // "Read Story", "Episode 12", "Chapter 7 of many": the last segment's+        // leading run is the chapter and the first segment names the Work.+        let title = "Read Story | Episode 12 :: Chapter 7"+        let state = Self.marking(title, subdividing: [2], roles: [.ignore, .work, .chapter, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .edgeTrim)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == " 7")++        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: title).get())+        #expect(parsed.workName == "Episode 12")+        #expect(parsed.chapterTitle == "Chapter")+    }++    // MARK: - Trim exactness (Req 3.5, Q19)++    @Test("The prefix trim stops at the first segment's kept run, never crossing a delimiter")+    func prefixTrimStopsAtTheFirstSegmentsKeptRun() async throws {+        // Segments: "Read Story", "TtH", "Ch 5". Only the first segment's+        // discarded head becomes the trim; the ignored second segment stays a+        // positional ignore rather than being swallowed by the trim (Q19).+        let title = "Read Story - TtH - Ch 5"+        let state = Self.marking(title, subdividing: [0], roles: [.ignore, .work, .ignore, .chapter])++        let rule = try #require(state.rule)+        #expect(state.branch == .edgeTrim)+        #expect(rule.trimPrefix == "Read ")+        #expect(rule.trimSuffix == nil)++        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: title).get())+        #expect(parsed.workName == "Story")+        #expect(parsed.chapterTitle == "Ch 5")+    }++    @Test("The trim is the exact source text: the double-space family derives its own")+    func doubleSpaceFamilyDerivesItsOwnExactTrim() async throws {+        // Two spaces between "Read" and the Work name: the trim is the exact+        // slice, both spaces included (Req 3.5).+        let doubled = "Read  Story :: Episode 12 | Site"+        let state = Self.marking(doubled, subdividing: [0], roles: [.ignore, .work, .chapter, .ignore])+        let rule = try #require(state.rule)+        #expect(rule.trimPrefix == "Read  ")++        // Off-by-one counterexample: the exact-scalar trim does not apply to the+        // single-space family, and the applicator fails open rather than+        // trimming a near-match.+        let single = "Read Story :: Episode 12 | Site"+        let parsed = try #require(try? TitleRuleApplicator.apply(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix,+            to: single).get())+        #expect(parsed.workName == "Read Story")+    }++    // MARK: - Non-triggering states keep today's inference (Req 3.3)++    @Test("Without a whole-segment field marking the trigger stays shut: today's whole-title plus trims stands")+    func edgeRunAloneDoesNotTrigger() async throws {+        // Decision 3: an edge run with nothing else marked keeps authoring+        // whole-title plus trims, and those trims run across the title's own+        // separators — exactly the shape Q10 says must never become a+        // positional rule derived from the untrimmed title.+        let title = "TtH - Story - Real Title"+        let state = Self.marking(title, subdividing: [2], roles: [.ignore, .ignore, .work, .ignore])++        let rule = try #require(state.rule)+        #expect(state.branch == .wholeTitleWithTrims)+        #expect(rule.definition == .wholeTitle)+        #expect(rule.trimPrefix == "TtH - Story - ")+        #expect(rule.trimSuffix == " Title")+    }++    @Test("A trailing run of the last segment is the wrong side and does not trigger")+    func trailingRunOfTheLastSegmentDoesNotTrigger() async throws {+        // Req 3.2 admits only a *leading* run of the last segment; keeping its+        // trailing part instead keeps today's phrase inference.+        let title = "TtH - Story - Real Title"+        let state = Self.marking(title, subdividing: [2], roles: [.chapter, .ignore, .ignore, .work])++        let rule = try #require(state.rule)+        #expect(state.branch == .phrase)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == nil)+    }++    @Test("A part-marking in an interior segment does not trigger")+    func interiorPartMarkingDoesNotTrigger() async throws {+        let title = "TtH - Read Story - Ch 5"+        let state = Self.marking(title, subdividing: [1], roles: [.ignore, .ignore, .work, .chapter])++        let rule = try #require(state.rule)+        #expect(state.branch == .phrase)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == nil)+    }++    @Test("A single-segment title keeps the whole-title-plus-trims form")+    func singleSegmentTitleKeepsWholeTitlePlusTrims() async throws {+        // No other whole segment exists to mark, so Decision 3's trigger cannot+        // fire and the existing form survives unchanged.+        let title = "Read Real Title"+        let state = Self.marking(title, subdividing: [0], roles: [.ignore, .work, .work])++        let rule = try #require(state.rule)+        #expect(state.branch == .wholeTitleWithTrims)+        #expect(rule.definition == .wholeTitle)+        #expect(rule.trimPrefix == "Read ")+        #expect(rule.trimSuffix == nil)+    }++    // MARK: - Trigger totality and inference soundness++    /// Req 3.2 restated over the reclassified marking, independent of the+    /// pipeline's own control flow. The trigger is only ever asked about+    /// markings the unchanged semantic guards admit (Req 8.6: a non-empty+    /// contiguous Work run and a contiguous chapter run), so the restatement+    /// carries them too.+    private static func triggerHolds(_ state: Marking) -> Bool {+        let (chips, roles) = ComposedTeachingPresentation.reclassified(+            segments: state.segments, chips: state.chips, roles: state.roles)+        let workIndices = roles.indices.filter { roles[$0] == .work }+        let chapterIndices = roles.indices.filter { roles[$0] == .chapter }+        func contiguous(_ indices: [Int]) -> Bool {+            guard let first = indices.first, let last = indices.last else { return true }+            return indices.count == last - first + 1+        }+        guard !workIndices.isEmpty, contiguous(workIndices), contiguous(chapterIndices) else { return false }+        guard state.segments.count >= 2 else { return false }+        guard chips.indices.contains(where: { !chips[$0].isPart && roles[$0] != .ignore })+        else { return false }+        let markedParts = chips.indices.filter { chips[$0].isPart && roles[$0] != .ignore }+        guard !markedParts.isEmpty else { return false }++        var covered: Set<Int> = []+        for (segmentIndex, isTrailingRun) in [(0, true), (state.segments.count - 1, false)] {+            let group = chips.indices.filter { chips[$0].isPart && chips[$0].segmentIndex == segmentIndex }+            let marked = group.filter { roles[$0] != .ignore }+            guard let first = marked.first, let last = marked.last, let outerFirst = group.first,+                  let outerLast = group.last, marked.count < group.count,+                  Set(marked.map { roles[$0] }).count == 1,+                  last - first + 1 == marked.count,+                  isTrailingRun ? last == outerLast : first == outerFirst+            else { continue }+            covered.formUnion(marked)+        }+        return markedParts.allSatisfy(covered.contains)+    }++    /// Titles and subdivisions swept exhaustively over every role assignment.+    private static let sweepFixtures: [(title: String, subdividing: Set<Int>)] = [+        ("Alpha Beta Gamma", []),+        ("Alpha Beta Gamma", [0]),+        ("Read Alpha :: Beta | Gamma", []),+        ("Read Alpha :: Beta | Gamma", [0]),+        ("Read Alpha :: Beta | Gamma Delta", [0, 2]),+        ("Read Alpha :: Beta | Gamma - Delta Eps", [0, 3]),+    ]++    @Test("Every marking state lands in exactly one branch, and the edge-trim branch is the Req 3.2 trigger")+    func triggerTotality() async throws {+        var mismatches: [String] = []+        for fixture in Self.sweepFixtures {+            let segments = ComposedTeachingPresentation.titleSegments(in: fixture.title)+            let chips = ComposedTeachingPresentation.titleChips(+                segments: segments, subdividing: fixture.subdividing)+            for roles in Self.roleAssignments(count: chips.count) {+                let state = Marking(+                    title: fixture.title, segments: segments, chips: chips, roles: roles)+                let (branch, rule) = state.outcome++                // The branch is one value of a closed enum, and it is the branch+                // the rule actually came out of.+                switch branch {+                case .unauthorable:+                    #expect(rule == nil, "\(fixture.title) \(roles)")+                case .wholeTitle:+                    #expect(rule?.definition == .wholeTitle)+                    #expect(rule?.trimPrefix == nil && rule?.trimSuffix == nil)+                case .segmentForm:+                    #expect(rule?.trimPrefix == nil && rule?.trimSuffix == nil)+                    #expect(Self.isPositional(rule?.definition), "\(fixture.title) \(roles)")+                case .edgeTrim:+                    #expect(Self.isPositional(rule?.definition), "\(fixture.title) \(roles)")+                    #expect(rule?.trimPrefix != nil || rule?.trimSuffix != nil)+                case .wholeTitleWithTrims:+                    #expect(rule?.definition == .wholeTitle)+                case .phrase:+                    #expect(Self.isPhrase(rule?.definition), "\(fixture.title) \(roles)")+                    #expect(rule?.trimPrefix == nil && rule?.trimSuffix == nil)+                }++                if (branch == .edgeTrim) != Self.triggerHolds(state) {+                    mismatches.append("\(fixture.title) | \(fixture.subdividing) | \(roles) → \(branch)")+                }+            }+        }+        #expect(mismatches.isEmpty, "\(mismatches.count) trigger mismatches: \(mismatches.prefix(8))")+    }++    @Test("Every authorable marking state's rule validates and re-parses its own title")+    func inferenceSoundness() async throws {+        let capabilities = AsterismCapabilities.m4+        for fixture in Self.sweepFixtures {+            let segments = ComposedTeachingPresentation.titleSegments(in: fixture.title)+            let chips = ComposedTeachingPresentation.titleChips(+                segments: segments, subdividing: fixture.subdividing)+            for roles in Self.roleAssignments(count: chips.count) {+                let state = Marking(+                    title: fixture.title, segments: segments, chips: chips, roles: roles)+                guard let rule = state.rule,+                      (try? capabilities.validate(patternDefinition: rule.definition)) != nil+                else { continue }++                let label = "\(fixture.title) \(fixture.subdividing) \(roles)"+                guard case .success(let parsed) = TitleRuleApplicator.apply(+                    definition: rule.definition, trimPrefix: rule.trimPrefix,+                    trimSuffix: rule.trimSuffix, to: fixture.title) else {+                    Issue.record("rule does not parse its own title: \(label)")+                    continue+                }+                for index in chips.indices where roles[index] == .work {+                    #expect(parsed.workName.contains(chips[index].text), "work value lost: \(label)")+                }+                let markedChapter = roles.contains(.chapter)+                #expect(markedChapter == (parsed.chapterTitle != nil), "chapter presence: \(label)")+                for index in chips.indices where roles[index] == .chapter {+                    #expect(+                        parsed.chapterTitle?.contains(chips[index].text) == true,+                        "chapter value lost: \(label)")+                }+            }+        }+    }++    private static func isPositional(_ definition: PatternDefinition?) -> Bool {+        switch definition {+        case .segment, .chapterlessSegment: true+        default: false+        }+    }++    private static func isPhrase(_ definition: PatternDefinition?) -> Bool {+        switch definition {+        case .phrase, .chapterlessPhrase: true+        default: false+        }+    }+}
Asterism/AsterismTests/URLRepairThroughEditorTests.swift Modified +304 / −2
diff --git a/Asterism/AsterismTests/URLRepairThroughEditorTests.swift b/Asterism/AsterismTests/URLRepairThroughEditorTests.swiftindex 94b494e..288dcbb 100644--- a/Asterism/AsterismTests/URLRepairThroughEditorTests.swift+++ b/Asterism/AsterismTests/URLRepairThroughEditorTests.swift@@ -142,7 +142,16 @@ struct URLRepairThroughEditorTests {         var editor = EditorState()         editor.seed(from: stored, in: components)         #expect(editor.work == .path(site.chip), "\(site): the stored rule seeds the chip")-        editor.select(.path(site.chip))+        // Through the refusing entry point, as a reader's tap is: the repair has+        // to be reachable from the chip row, not only from the state (Req 1.4).+        #expect(editor.selectComponent(.path(site.chip), in: components) == nil, "\(site)")+        // The stored anchoring is reflected into the controls and survives the+        // re-tap (Req 1.9 of `teach-editor-authoring-gaps`), so the repair is+        // the anchoring choice rather than the tap: each of these three sites is+        // pinned by a story-specific right-hand neighbour.+        #expect(+            editor.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: components)+                == nil, "\(site)")         let authored = try #require(editor.rule(in: components).definition, "\(site)")         #expect(authored == site.repaired, "\(site)") @@ -214,7 +223,7 @@ struct URLRepairThroughEditorTests {         #expect(editor.work == .path(1))         #expect(editor.sequence == .path(2))         editor.clear()-        editor.select(.path(1))+        #expect(editor.selectComponent(.path(1), in: components) == nil)         let authored = try #require(editor.rule(in: components).definition)         #expect(authored == site.repaired) @@ -249,6 +258,299 @@ struct URLRepairThroughEditorTests {         #expect(try fixture.entriesWithoutURLIdentity().isEmpty)     } +    // MARK: - tapas.io: the chapter-unsourced nudge (Reqs 2.1, 2.2, 2.3)++    /// The collision `teach-editor-authoring-gaps` exists to close: the nudge+    /// invites selecting the episode component, whose left neighbour is the+    /// story's own slug, so the only locator the old builder could author was+    /// pinned to one story. The chapter slot's default now anchors the path's+    /// end instead, and the rule holds across the site's works.+    @Test("Following the chapter nudge authors a chapter locator that holds across the site")+    func tapasChapterSourcingGeneralisesAcrossWorks() async throws {+        let site = SiteCase(+            hostname: "tapas.io", captures: ArchiveCorpus.tapasCaptures,+            stored: Self.tapasStored, exampleIndex: 0, chip: 1,+            repaired: Self.tapasStored, keyVersion: 1, resolvedBeforeRepair: 1)+        var fixture = try RepairFixture(hostname: site.hostname, captures: site.captures)+        defer { fixture.cleanup() }+        try await fixture.seedAndTeach(site.stored, exampleIndex: site.exampleIndex)+        let before = try fixture.identityKeys()++        let model = try await fixture.viewModel(for: fixture.entryIDs[site.exampleIndex])+        await model.load()+        try await Self.waitForPreview(model, site)+        // The nudge state: neither rule sources a chapter, so the URL details+        // present themselves as the remedy rather than as a passive hint.+        #expect(model.chapterUnsourced, "\(site)")+        #expect(model.disclosureState == .expanded, "\(site)")++        let components = try RawURLRuleParser.parse(+            ExactScalarString(fixture.captures[site.exampleIndex].url))+        let stored = try #require(model.urlRuleDefinition, "\(site)")+        var editor = EditorState()+        editor.seed(from: stored, in: components)+        // The Work side is unpinned from this story's episode, exactly as the+        // repair test does…+        #expect(+            editor.chooseAnchor(slot: .work, side: .right, anchor: .unanchored, in: components)+                == nil, "\(site)")+        // …and the nudge is followed: the last path component into the chapter+        // slot, tapped rather than assigned.+        editor.activeSlot = .sequence+        #expect(editor.selectComponent(.path(2), in: components) == nil, "\(site)")++        let outcome = editor.rule(in: components)+        guard case .valid(.workAndSequence(let work, let sequence)) = outcome.status else {+            Issue.record("\(site): expected an identity-plus-sequence rule, got \(outcome.status)")+            return+        }+        #expect(work.locator+            == .pathBracketed(left: .literal(ExactScalarString("series")), right: .unanchored))+        // Req 2.1: no gesture chose this — it is the chapter slot's default, and+        // it is derived rather than written into the anchoring, so it recomputes.+        #expect(sequence.locator == .pathBracketed(left: .unanchored, right: .end))+        #expect(editor.sequenceAnchoring.left == nil, "\(site): a default is never written in")+        #expect(editor.sequenceAnchoring.right == nil, "\(site)")++        // Req 2.3: taught on `/series/{storyA}/{episode}`, it selects the+        // episode component of a different story's URL without re-teaching.+        let otherURL = try #require(fixture.captures.last?.url, "\(site)")+        let other = try RawURLRuleParser.parse(ExactScalarString(otherURL))+        #expect(try URLRuleApplicator.select(work.locator, from: other) == ExactScalarString("eleceed"))+        #expect(try URLRuleApplicator.select(sequence.locator, from: other) == ExactScalarString("ep2"))++        // Req 2.2: the left side stays the reader's to override, back onto the+        // story's own slug — on a copy, so the assertions above stand.+        var overridden = editor+        #expect(+            overridden.chooseAnchor(+                slot: .sequence, side: .left,+                anchor: .literal(ExactScalarString("the-regressor-creates-everything")),+                in: components) == nil, "\(site)")+        // Overriding one side leaves two valid completions of the other — "before+        // whatever follows the episode" and "at the path's end" are different+        // rules — so the editor asks instead of picking (Req 1.2's third+        // outcome). The default that stood is not silently kept either.+        guard case .pending(let message) = overridden.rule(in: components).status else {+            Issue.record("\(site): expected the overridden slot to open undecided")+            return+        }+        #expect(message.contains("after it"), "\(site)")+        #expect(message.contains(ComposedTeachingPresentation.sequenceSlotLabel), "\(site)")+        #expect(+            overridden.chooseAnchor(slot: .sequence, side: .right, anchor: .end, in: components)+                == nil, "\(site)")+        guard case .valid(.workAndSequence(_, let pinned)) = overridden.rule(in: components).status+        else {+            Issue.record("\(site): expected the overridden rule to stay a two-locator rule")+            return+        }+        #expect(pinned.locator == .pathBracketed(+            left: .literal(ExactScalarString("the-regressor-creates-everything")), right: .end))++        model.updateURLRule(outcome)+        try await Self.waitForPreview(model, site)+        let preview = try #require(model.previewOutcome, "\(site)")++        // The chapter is sourced and every capture on the site resolves.+        #expect(!model.chapterUnsourced, "\(site)")+        #expect(model.unresolvedURLCaptures.isEmpty, "\(site): \(model.unresolvedURLCaptures)")+        #expect(preview.urlVersion == .available(2), "\(site)")+        // Req 1.12: sourcing the chapter re-keys the captures, and the+        // projection says so before the reader commits.+        #expect(!model.identityKeyChanges.isEmpty, "\(site)")+        #expect(model.identityKeyChangeNotice != nil, "\(site)")+        #expect(try fixture.identityKeys() == before, "\(site): nothing has moved yet")+    }++    // MARK: - m.fanfiction.net reopened on another chapter (Reqs 1.6, 1.7, 1.11, 1.12)++    /// The site taught from `/s/14545097/1/The-Club`, whose Work locator pins the+    /// chapter number as its right anchor.+    private static var fanfictionChapterTwo: ArchiveCorpus.Capture {+        ArchiveCorpus.Capture(title: "Test", url: "https://m.fanfiction.net/s/14545097/2/The-Club")+    }++    private static func fanfictionSite(withChapterTwo: Bool) -> SiteCase {+        SiteCase(+            hostname: "m.fanfiction.net",+            captures: withChapterTwo+                ? ArchiveCorpus.fanfictionCaptures + [fanfictionChapterTwo]+                : ArchiveCorpus.fanfictionCaptures,+            stored: fanfictionStored, exampleIndex: 0, chip: 1,+            repaired: fanfictionStored, keyVersion: 2, resolvedBeforeRepair: 1)+    }++    /// Req 1.11: the stored `.literal("1")` right anchor is dropped by the+    /// default the moment the component is re-selected — and the control puts it+    /// back, which is the whole reason the right side needed a genuine control.+    @Test("A stored right anchor the default drops can be re-pinned from the same URL")+    func fanfictionRightAnchorIsRePinnable() async throws {+        let site = Self.fanfictionSite(withChapterTwo: false)+        var fixture = try RepairFixture(hostname: site.hostname, captures: site.captures)+        defer { fixture.cleanup() }+        try await fixture.seedAndTeach(site.stored, exampleIndex: 0)++        let model = try await fixture.viewModel(for: try #require(fixture.entryIDs.first))+        await model.load()+        try await Self.waitForPreview(model, site)+        let stored = try #require(model.urlRuleDefinition, "\(site)")++        let components = try RawURLRuleParser.parse(ExactScalarString(fixture.captures[0].url))+        var editor = EditorState()+        editor.seed(from: stored, in: components)+        // Reopened on the URL it was taught from, the stored pair is reflected.+        #expect(editor.workAnchoring.right == .literal(ExactScalarString("1")), "\(site)")++        // A fresh teach of the same component derives the unanchored right side.+        editor.clear()+        #expect(editor.selectComponent(.path(1), in: components) == nil, "\(site)")+        #expect(+            editor.rule(in: components).definition+                == .work(locator: .pathBracketed(+                    left: .literal(ExactScalarString("s")), right: .unanchored)), "\(site)")++        // Req 1.1: the neighbouring literal is on offer on that side, and Req+        // 1.11's control pins it back.+        #expect(+            ComposedTeachingPresentation.offeredAnchors(side: .right, at: 1, in: components)+                .contains(.literal(ExactScalarString("1"))), "\(site)")+        #expect(+            editor.chooseAnchor(+                slot: .work, side: .right, anchor: .literal(ExactScalarString("1")),+                in: components) == nil, "\(site)")+        // Pinning the right side leaves two valid completions of the left — the+        // `s` literal and unanchored both single out this component — so the+        // half-chosen pair opens undecided rather than being auto-completed+        // (Reqs 1.2, 1.8), and the reader names the other side.+        guard case .pending(let message) = editor.rule(in: components).status else {+            Issue.record("\(site): expected the half-chosen pair to open undecided")+            return+        }+        #expect(message.contains("before it"), "\(site)")+        #expect(+            editor.chooseAnchor(+                slot: .work, side: .left, anchor: .literal(ExactScalarString("s")),+                in: components) == nil, "\(site)")+        // Req 1.11: the stored anchoring is back, pinned by the control rather+        // than recovered from the stored rule.+        #expect(+            editor.rule(in: components).definition+                == .work(locator: .pathBracketed(+                    left: .literal(ExactScalarString("s")),+                    right: .literal(ExactScalarString("1")))), "\(site)")+    }++    /// Req 1.6 with Req 1.7's second branch: reopened on chapter 2, the stored+    /// Work locator's `.literal("1")` right anchor is nowhere on the URL, so it+    /// seeds no chip and the controls cannot show it. The locator is retained+    /// instead, and an unedited recommit is the stored rule verbatim — which the+    /// version projection reports as no change at all.+    @Test("Reopened where its anchoring cannot be shown, an unedited recommit projects unchanged")+    func fanfictionReopenedOnChapterTwoRecommitsUnchanged() async throws {+        let site = Self.fanfictionSite(withChapterTwo: true)+        var fixture = try RepairFixture(hostname: site.hostname, captures: site.captures)+        defer { fixture.cleanup() }+        try await fixture.seedAndTeach(site.stored, exampleIndex: 0)+        let before = try fixture.identityKeys()++        let chapterTwoID = fixture.entryIDs[1]+        let model = try await fixture.viewModel(for: chapterTwoID)+        await model.load()+        try await Self.waitForPreview(model, site)+        let stored = try #require(model.urlRuleDefinition, "\(site)")+        #expect(stored == site.stored, "\(site)")++        let components = try RawURLRuleParser.parse(ExactScalarString(Self.fanfictionChapterTwo.url))+        var editor = EditorState()+        editor.seed(from: stored, in: components)++        // The Work slot: no chip, the stored locator retained and unedited —+        // the state the Req 1.7 notice renders from.+        #expect(editor.work == nil, "\(site)")+        guard case .workAndSequence(let storedWork, let storedSequence) = stored else {+            Issue.record("\(site): the stored rule is a two-locator rule")+            return+        }+        #expect(editor.workAnchoring.retainedLocator == storedWork.locator, "\(site)")+        #expect(!editor.workAnchoring.edited, "\(site)")+        #expect(editor.workAnchoring.left == nil, "\(site): nothing depicts an anchoring that is not the stored one")+        #expect(editor.workAnchoring.right == nil, "\(site)")+        // The sequence side does resolve here, at the chapter component.+        #expect(editor.sequence == .path(2), "\(site)")++        // Req 1.6: the rule the untouched editor authors is the stored one,+        // locator for locator.+        let outcome = editor.rule(in: components)+        #expect(outcome.status == .valid(stored), "\(site)")+        #expect(storedSequence.locator == .pathBracketed(+            left: .literal(ExactScalarString("14545097")),+            right: .literal(ExactScalarString("The-Club"))), "\(site)")++        model.updateURLRule(outcome)+        try await Self.waitForPreview(model, site)+        let preview = try #require(model.previewOutcome, "\(site)")+        #expect(preview.urlVersion == .unchanged(1), "\(site): \(String(describing: preview.urlVersion))")+        #expect(model.identityKeyChanges.isEmpty, "\(site): \(model.identityKeyChanges)")+        #expect(try fixture.identityKeys() == before, "\(site)")+    }++    /// Req 1.12: the same reopen, with the reader tapping the Work component —+    /// which replaces the retained locator with the default's unanchored right+    /// side. That is a different rule, and the projection reports both the new+    /// version and the capture it re-keys before the commit.+    @Test("An anchoring change is reported as a new version and its re-keying, before the commit")+    func fanfictionAnchoringChangeReportsVersionAndRekeying() async throws {+        let site = Self.fanfictionSite(withChapterTwo: true)+        var fixture = try RepairFixture(hostname: site.hostname, captures: site.captures)+        defer { fixture.cleanup() }+        try await fixture.seedAndTeach(site.stored, exampleIndex: 0)+        let before = try fixture.identityKeys()+        let chapterOneID = fixture.entryIDs[0]+        let chapterTwoID = fixture.entryIDs[1]+        // Chapter 2 does not resolve under the stored rule, so it holds a+        // conservative key while chapter 1 holds an identity-plus-chapter one.+        #expect(before[chapterOneID]?.version == 2, "\(site)")+        #expect(before[chapterTwoID]?.version == 1, "\(site)")++        let model = try await fixture.viewModel(for: chapterTwoID)+        await model.load()+        try await Self.waitForPreview(model, site)+        let stored = try #require(model.urlRuleDefinition, "\(site)")++        let components = try RawURLRuleParser.parse(ExactScalarString(Self.fanfictionChapterTwo.url))+        var editor = EditorState()+        editor.seed(from: stored, in: components)+        // The tap: nil→component is a replacement, so the retained locator goes+        // and the default anchors the Work component's right side nowhere.+        #expect(editor.selectComponent(.path(1), in: components) == nil, "\(site)")+        #expect(editor.workAnchoring.retainedLocator == nil, "\(site)")++        let outcome = editor.rule(in: components)+        let authored = try #require(outcome.definition, "\(site)")+        #expect(authored != stored, "\(site): the anchoring changed")+        #expect(authored == .workAndSequence(+            work: URLFieldSelector(locator: .pathBracketed(+                left: .literal(ExactScalarString("s")), right: .unanchored)),+            sequence: URLFieldSelector(locator: .pathBracketed(+                left: .literal(ExactScalarString("14545097")),+                right: .literal(ExactScalarString("The-Club"))))), "\(site)")++        model.updateURLRule(outcome)+        try await Self.waitForPreview(model, site)+        let preview = try #require(model.previewOutcome, "\(site)")++        // The change: a new rule version, and the one capture it newly resolves+        // moving off its conservative key — both before anything is committed.+        #expect(preview.urlVersion == .available(2), "\(site): \(String(describing: preview.urlVersion))")+        #expect(model.identityKeyChanges.map(\.entryID) == [chapterTwoID], "\(site)")+        #expect(model.identityKeyChanges.first?.fromVersion == 1, "\(site)")+        #expect(model.identityKeyChanges.first?.toVersion == 2, "\(site)")+        #expect(model.identityKeyChangeNotice != nil, "\(site)")+        #expect(model.unresolvedURLCaptures.isEmpty, "\(site)")+        #expect(try fixture.identityKeys() == before, "\(site): nothing has moved yet")+    }+     // MARK: - Helpers      private static func workCollisions(_ issues: [URLIdentityIssue]) -> [URLIdentityIssue] {
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift Modified +160 / −0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swiftindex 0d7f124..69533f5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift@@ -265,6 +265,166 @@ struct ComposedTeachingRepositoryTests {         #expect(e1Entry.identityKeyVersion == 1)     } +    // MARK: - Identity-matched Work renaming (Req 3.21)++    /// `Read <work> :: Episode <n> | Tapas Comics` — Work first, site name last+    /// and ignored, chapter in the middle.+    private func tapasSegment() throws -> PatternDefinition {+        .segment(+            work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+            ignored: [try SegmentPositionSpec(origin: .end, offset: 0)])+    }++    /// `/series/<identity>/<sequence>`: the component after `series` is the Work+    /// identity, the last component is the chapter sequence.+    private func tapasURL() -> URLRuleDefinition {+        .workAndSequence(+            work: URLFieldSelector(+                locator: .pathBracketed(+                    left: .literal(ExactScalarString("series")), right: .unanchored)),+            sequence: URLFieldSelector(locator: .pathBracketed(left: .unanchored, right: .end)))+    }++    private func seedTapasEpisodes(_ fixture: ComposedRepoFixture, _ e10: UUID, _ e11: UUID) throws {+        try fixture.seedUntaught([+            (e10, "Read The Regressor Creates Everything :: Episode 10 | Tapas Comics",+                "https://ex.com/series/regressor/ep10"),+            (e11, "Read The Regressor Creates Everything :: Episode 11 | Tapas Comics",+                "https://ex.com/series/regressor/ep11"),+        ])+    }++    @Test("Re-teaching a trim renames the identity-matched Work (Req 3.21)")+    func reteachWithTrimRenamesIdentityMatchedWork() async throws {+        let fixture = try ComposedRepoFixture()+        let e10 = UUID(), e11 = UUID()+        try seedTapasEpisodes(fixture, e10, e11)++        // First teach: no trims, so the Work keeps the `Read ` prefix and takes+        // its URL identity from the rule.+        let untrimmed = try await fixture.repository.projectComposedTeaching(+            hostname: host,+            request: ComposedTeachingRequest(+                titleDefinition: try tapasSegment(), urlDefinition: tapasURL()))+        guard case .committed = try await fixture.repository.commitComposedTeaching(untrimmed) else {+            Issue.record("expected the untrimmed teach to commit"); return+        }+        let seeded = fixture.freshContext()+        let seededWork = try #require(try seeded.fetch(FetchDescriptor<Work>()).first)+        #expect(seededWork.urlIdentity == "regressor")   // identity-matched from here on+        #expect(seededWork.displayTitle == "Read The Regressor Creates Everything")++        // Re-teach the same rules with a leading trim.+        let trimmed = try await fixture.repository.projectComposedTeaching(+            hostname: host,+            request: ComposedTeachingRequest(+                titleDefinition: try tapasSegment(), trimPrefix: "Read ",+                urlDefinition: tapasURL()))+        for projection in trimmed.outcome.entries {+            #expect(projection.workName == "The Regressor Creates Everything")+        }+        guard case .committed = try await fixture.repository.commitComposedTeaching(trimmed) else {+            Issue.record("expected the trimmed re-teach to commit"); return+        }++        let context = fixture.freshContext()+        #expect(try LibraryValidator.validate(context: context).tupleDiagnoses[host] == nil)+        let works = try context.fetch(FetchDescriptor<Work>())+        #expect(works.count == 1)   // the identity match reused the same Work+        let work = try #require(works.first)+        // Req 3.21: the identity match refreshes both titles, because the Work's+        // title provenance is parsed.+        #expect(work.displayTitle == "The Regressor Creates Everything")+        #expect(work.lastParsedTitle == "The Regressor Creates Everything")+    }++    @Test("A manual display title survives the rename an identity match applies (Req 3.21)")+    func manualDisplayTitleSurvivesRename() async throws {+        let fixture = try ComposedRepoFixture()+        let e10 = UUID(), e11 = UUID()+        try seedTapasEpisodes(fixture, e10, e11)++        let untrimmed = try await fixture.repository.projectComposedTeaching(+            hostname: host,+            request: ComposedTeachingRequest(+                titleDefinition: try tapasSegment(), urlDefinition: tapasURL()))+        guard case .committed = try await fixture.repository.commitComposedTeaching(untrimmed) else {+            Issue.record("expected the untrimmed teach to commit"); return+        }++        // The reader renamed the Work by hand.+        try fixture.seed { context in+            guard let work = (try? context.fetch(FetchDescriptor<Work>()))?.first else { return }+            work.displayTitle = "Regressor (my name for it)"+            work.titleProvenanceRaw = TitleProvenance.manual.rawValue+        }++        let trimmed = try await fixture.repository.projectComposedTeaching(+            hostname: host,+            request: ComposedTeachingRequest(+                titleDefinition: try tapasSegment(), trimPrefix: "Read ",+                urlDefinition: tapasURL()))+        guard case .committed = try await fixture.repository.commitComposedTeaching(trimmed) else {+            Issue.record("expected the trimmed re-teach to commit"); return+        }++        let context = fixture.freshContext()+        let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+        // `lastParsedTitle` still tracks the rule; the manual display title does not.+        #expect(work.lastParsedTitle == "The Regressor Creates Everything")+        #expect(work.displayTitle == "Regressor (my name for it)")+    }++    // MARK: - One rule for both tapas title families (Req 3.6)++    /// Req 3.6 at the repository layer: the trimmed positional rule the editor+    /// now authors parses **both** families tapas serves on one hostname —+    /// `Read {work} ::  Episode N | Tapas Comics`, with its double space, and+    /// `Read {work} :: Chapter N | Tapas Novels` — onto one Work, each capture+    /// keeping its own chapter. The phrase form this replaces stored the family+    /// tail as a literal, so each family's teach invalidated the other's and one+    /// group stayed flagged for re-teaching.+    @Test("One trimmed segment rule covers both tapas title families (Req 3.6)")+    func trimmedRuleCoversBothTapasFamilies() async throws {+        let fixture = try ComposedRepoFixture()+        let comics = UUID(), novels = UUID()+        try fixture.seedUntaught([+            (comics, "Read The Regressor Creates Everything ::  Episode 12 | Tapas Comics",+                "https://ex.com/series/regressor/12"),+            (novels, "Read The Regressor Creates Everything :: Chapter 5 | Tapas Novels",+                "https://ex.com/series/regressor/5"),+        ])++        let contract = try await fixture.repository.projectComposedTeaching(+            hostname: host,+            request: ComposedTeachingRequest(+                titleDefinition: try tapasSegment(), trimPrefix: "Read ",+                urlDefinition: tapasURL()))+        // The title half on its own: both families yield the same Work name.+        for projection in contract.outcome.entries {+            #expect(projection.workName == "The Regressor Creates Everything")+        }+        guard case .committed = try await fixture.repository.commitComposedTeaching(contract) else {+            Issue.record("expected the trimmed teach to commit"); return+        }++        let context = fixture.freshContext()+        #expect(try LibraryValidator.validate(context: context).tupleDiagnoses[host] == nil)+        let works = try context.fetch(FetchDescriptor<Work>())+        #expect(works.count == 1)+        #expect(works.first?.displayTitle == "The Regressor Creates Everything")++        let entries = try context.fetch(FetchDescriptor<Entry>())+        let comicsEntry = try #require(entries.first { $0.id == comics })+        let novelsEntry = try #require(entries.first { $0.id == novels })+        #expect(comicsEntry.work?.id == novelsEntry.work?.id)+        // Each family keeps its own chapter, number included.+        #expect(comicsEntry.chapterTitle == "Episode 12")+        #expect(novelsEntry.chapterTitle == "Chapter 5")+        #expect(comicsEntry.chapterSequence == "12")+        #expect(novelsEntry.chapterSequence == "5")+    }+     // MARK: - Quarantine repair (Q28)      @Test("Composed re-teach on a quarantined Site clears the quarantine (Q28)")
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift Modified +52 / −0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swiftindex f6776e6..fe0e52e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedRecalculationTests.swift@@ -83,6 +83,58 @@ struct ComposedRecalculationTests {         }     } +    // MARK: - A stale Work name is a change (Reqs 3.21, 3.26)++    @Test("Recalculation repairs a Work whose name is the only stale value")+    func recalcRepairsStaleWorkName() async throws {+        let fixture = try ComposedRecalcFixture()+        try fixture.seedUntaught([+            (UUID(), "Read The Regressor Creates Everything :: Episode 10 | Tapas Comics",+                "https://ex.com/series/regressor/ep10")+        ])+        let request = ComposedTeachingRequest(+            titleDefinition: .segment(+                work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+                ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]),+            trimPrefix: "Read ",+            urlDefinition: .workAndSequence(+                work: URLFieldSelector(+                    locator: .pathBracketed(+                        left: .literal(ExactScalarString("series")), right: .unanchored)),+                sequence: URLFieldSelector(locator: .pathBracketed(left: .unanchored, right: .end))))+        let taught = try await fixture.repository.projectComposedTeaching(hostname: host, request: request)+        guard case .committed = try await fixture.repository.commitComposedTeaching(taught) else {+            Issue.record("teach failed"); return+        }++        // Drift only the Work's name — every Entry key, sequence, identity and+        // assignment still agrees with the current rules.+        try fixture.seed { context in+            guard let work = (try? context.fetch(FetchDescriptor<Work>()))?.first else { return }+            work.displayTitle = "Read The Regressor Creates Everything"+            work.lastParsedTitle = "Read The Regressor Creates Everything"+        }++        fixture.save.resetCounts()+        let contract = try await fixture.repository.previewRecalculation(hostname: host)+        let result = try await fixture.repository.commitRecalculation(contract)+        // Req 3.26: the name difference is a change, so recalculation applies it+        // rather than reporting `.noChanges` over a repair it can make.+        guard case .committed = result else { Issue.record("expected committed, got \(result)"); return }+        #expect(fixture.save.successCount == 1)++        let context = fixture.freshContext()+        let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+        #expect(work.displayTitle == "The Regressor Creates Everything")+        #expect(work.lastParsedTitle == "The Regressor Creates Everything")++        // And a second recalculation now has nothing left to do.+        let second = try await fixture.repository.previewRecalculation(hostname: host)+        guard case .noChanges = try await fixture.repository.commitRecalculation(second) else {+            Issue.record("expected noChanges on the second recalculation"); return+        }+    }+     @Test("Recalculation never creates or retires a rule")     func recalcReusesRuleVersions() async throws {         let fixture = try ComposedRecalcFixture()
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift Modified +21 / −0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swiftindex 5522cbe..60dfc5a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedCaptureTests.swift@@ -91,6 +91,27 @@ struct ComposedCaptureTests {         #expect(entry.work != nil)                                // title-matched     } +    @Test("A capture matched by URL identity refreshes the Work's parsed title (Req 3.21)")+    func captureIdentityMatchRefreshesWorkTitle() async throws {+        let fixture = try ComposedCaptureFixture()+        try fixture.seedUntaught([(UUID(), "Chapter 7 - Real Work", "https://ex.com/read?id=42&chapter=7")])+        try await fixture.teach(ComposedTeachingRequest(+            titleDefinition: try wcSegment(), urlDefinition: identitySequenceURL()))++        // The site renamed the Work; the next capture is matched on identity 42,+        // not on the title, and must still carry the new name onto the Work.+        _ = try await fixture.capture(+            title: "Chapter 9 - Real Work Renamed", rawURL: "https://ex.com/read?id=42&chapter=9")++        let context = fixture.freshContext()+        let works = try context.fetch(FetchDescriptor<Work>())+        #expect(works.count == 1)+        let work = try #require(works.first)+        #expect(work.urlIdentity == "42")+        #expect(work.lastParsedTitle == "Real Work Renamed")+        #expect(work.displayTitle == "Real Work Renamed")   // provenance is parsed+    }+     @Test("Conservative capture on an untaught Site carries the raw-URL alias")     func captureUntaughtConservative() async throws {         let fixture = try ComposedCaptureFixture()
CHANGELOG.md Modified +6 / −0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d714ab6..3fd3e5f 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -36,6 +36,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- The teach-editor-authoring-gaps feature is complete and verified end to end (Integration phase, `specs/teach-editor-authoring-gaps/`). The full unit bundle (499 tests) and the AsterismCore suite (1,309 tests) pass with zero compiler warnings at feature-authored lines — confirmed against a forced recompile, since a warm-cache build reports a false green — the `Packages/AsterismCore` diff for the feature itself is empty (the representation already carried both capabilities, Reqs 4.1/4.3; the one later core change on this branch is the separately-scoped Req 3.21 fix under Fixed, per Q31), no stored-rule migration path was touched, and the optional-chapter-sequence editor suite passes unchanged. The verification also swept the new presentation helpers into the file's existing `nonisolated` pattern, which Swift 6 language mode would otherwise reject.+- The per-side anchoring choice is now on screen (URL surface phase, `specs/teach-editor-authoring-gaps/`). Selecting a path component shows two menu rows — Before it and After it — reading as the path does ("after "Story-28614"", "at the end of the path"), with the derivation's pick annotated "(default)" so a value on screen is not mistaken for a decision that was made. A slot whose stored rule the controls cannot depict shows Choose… beside the stored-rule notice rather than defaults that are not the rule; an undecided pair holds the URL details open, names the missing side beside the save control ("Chapter sequence: choose what comes before it in the URL before saving"), and refuses the commit until the reader decides — an in-progress choice is no longer mistakable for a cleared rule anywhere downstream, previews and the removal warning included. A tap that no anchoring can single out is refused with the reason instead of silently accepted. The three repairs that motivated the feature are pinned end to end through reader gestures: tapas' chapter sourcing taught on one story generalises to the next, m.fanfiction.net's Work-identity locator can have its dropped `1` anchor pinned back by hand, and a taught site reopened on a different chapter recommits its rule unchanged — while an anchoring change that would re-key existing captures reports exactly which ones before anything is committed.+- The URL editor's state machine now models a per-side anchoring choice for path locators (URL editor state phase, `specs/teach-editor-authoring-gaps/`). Each slot — Work identity and chapter sequence — carries its own anchoring state; selecting a path component derives a default locator where exactly one presents itself (the chapter slot's whole last component defaults to the path's end, which retires the chapter-nudge collision), goes pending where the reader must choose a side, and refuses the tap outright — with a reason — where no anchoring the representation admits can single the component out. Choices are transactional: a refused gesture leaves every piece of editor state untouched, a stored anchoring reflected into the controls survives re-tapping the same chip, and a stored locator the controls cannot depict is retained byte-identically — query locators included — rather than silently narrowed, the defect class that once re-keyed forty captures. The editor's published outcome now distinguishes cleared, valid, pending, and unauthorable; the anchoring rows themselves and the plumbing that surfaces pending and refusal messages in the teaching screen land with the next phase, so none of this is visible in the UI yet.+- Title rules on the positional segment forms now carry prefix and suffix trims (Title side phase, `specs/teach-editor-authoring-gaps/`). A segment kept only in part — beside at least one whole-segment marking — infers the exact discarded edge text as a trim, so tapas' two title families ("Read {story} :: Episode N | Tapas Comics" and "Read {story} :: Chapter N | Tapas Novels") parse with a single rule that drops the "Read " prefix — and a subdivided segment whose parts are all kept and marked as one field counts as a whole-segment marking, moving it to the sturdier positional form with the same extracted value. The title section names what a rule's trims drop before the commit, and reopening a taught site seeds the controls from the stored trims when they reproduce on the current title, falling back to the default selection beside a stored-rule notice when they cannot.+ - The optional-chapter-sequence feature is complete and pinned end to end by integration tests over a real seeded library (End to end phase, `specs/optional-chapter-sequence/`). A separator-free capture under a declared-optional rule derives the story's Work identity and chapter 1, receives the same identity-key shape as its siblings — so a re-share differing only by fragment, trailing slash, or query noise edits the existing Entry instead of duplicating it — settles its chapter with no chapter title, and cites the rule and version as the identity's source. Teaching the declaration over the realistic pre-state re-derives the site onto one Work, reports the chapter-1 re-key before commit while every sibling's key stays byte-identical, and a later chapter-1-shaped capture attaches cleanly; protected entries and manually set fields survive; the synthetic split pre-state is reported as a Work collision and left for Work merge rather than silently reunited; and removing the declaration re-derives to the documented split post-state, confirming the removal warning describes exactly what happens. One spec correction fell out: a bare-host (`www.`-less) re-share was never collapsible — the hostname is part of the identity key and selects the Site — and chapter 1 behaves identically to its siblings there, which is the parity that actually matters (Q32). - Teaching a combined URL rule now offers the declaration itself (Teaching surface phase, `specs/optional-chapter-sequence/`). The rule details gain a toggle stating that the chapter part may be absent, enabled whenever a combined rule is in force and disabled where the rule would be unbounded — with neither a prefix nor a suffix, every separator-free value in the component would become a Work identity, so the control refuses rather than letting the commit fail later. The declaration is editor state: it survives re-splitting the same component, seeds from the stored rule on reopen so a taught declaration shows as still in force, and resets with the template it was declared on for every gesture that discards that template. The plain-language rule summary states the tolerance ("; the chapter part may be absent"); a capture whose URL cannot express this rule form says so instead of failing silently; and removing the declaration warns that captures without the chapter part will stop resolving a Work identity and that removal re-derives the site rather than restoring the old grouping. The commit preview now shows, for a capture whose URL has no chapter part, the Work identity it derives and its sequence marked as derived rather than read from the URL; at least one such capture is always shown past the six-row cap; and a capture whose Work attachment would change shows the move — detected by Work identity, so a move between two same-named Works still reports. - Duplicate rows of one URL rule are now reconciled by comparing what the rule *means* rather than the exact bytes it was stored as (Comparison and archive phase, `specs/optional-chapter-sequence/`). Two rows carrying one rule under different JSON key layouts — or differing only in a key the encoder writes conditionally — no longer copy bytes onto each other on every pass, which was a churned CloudKit record for a semantic no-op. A pair where one row's stored rule cannot be read is left alone entirely rather than overwritten in either direction, so a readable rule is never replaced by unreadable bytes and the standing diagnosis naming the broken row stays visible; re-teaching the site repairs it. Archive compatibility for the optional-sequence declaration is now pinned by tests: a backup written before the feature imports unchanged, a backup in which no rule declares optionality is byte-identical to what a pre-feature build writes, and a declared-optional rule survives export and re-import.@@ -93,6 +98,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- Fixed a Work keeping its old name after teaching a rule that changes what its name derives to — the first live tapas test of the title trims exposed it: the trim was stored and every preview showed the trimmed name, but the Work in the library stayed "Read The Regressor Creates Everything". The code that refreshes an existing Work's parsed title on re-teach ran only for Works *without* a URL identity, the exact inverse of what the requirement (Req 3.21, `specs/url-identity-re-share/`) says; the same inversion sat in the capture commit path, and Recalculate could not repair the name either because its change detector never compared Work names at all. All three are fixed: re-teaching, a capture matched by URL identity, and Recalculate now update an identity-matched Work's parsed title — while a name you set by hand is still preserved. This is the branch's one deliberate `AsterismCore` change (Q31); the defect predates this feature, which was merely the first thing able to reveal it. - Fixed a stored URL rule the app cannot read being silently replaced with a made-up one (Stop the fabrication phase, `specs/url-locator-generalisation/`). When a rule's stored definition failed to decode, the accessor answered with a fabricated stand-in that no real URL ever matches — so every capture citing the rule lost its URL identity, the library check blamed those captures rather than the rule, and a backup wrote the fabrication into the archive with a valid checksum, indistinguishable from a file you meant to keep. Reading such a rule now fails loudly instead: capture on the affected site still succeeds and simply takes the conservative path, teaching still opens so re-teaching the site remains the repair (and offers the next rule version, not a colliding version 1), and recalculation and merge leave every Work's identity exactly as it was. The library check now names the unreadable rule itself, and the finding clears the moment you re-teach. A backup export refuses outright — writing no file at all — while any record still cites the unreadable rule, and omits it with a logged note when nothing does. Writing a rule definition can also no longer corrupt it: a failed save used to store empty bytes, manufacturing exactly the unreadable rule described above; it now leaves the stored rule untouched. - Fixed a new library becoming permanently unopenable, and removed the first-run setup screen that caused it. A fresh install used to create its library and then withhold the marker that certifies it until you answered "import a backup or start empty" — a question with nothing behind it, since there was no library to import into yet. Anything writing in that gap left a library the app could no longer classify, and every later launch refused to open it with no way back short of deleting the app. The library is now marked the moment it is created, so a fresh install opens straight into an empty library and the question is gone. One consequence worth knowing: the share extension now works from first launch instead of waiting for that answer, so a page shared before you restore a backup will be in the library when you do — restoring still shows you what it is about to discard and still asks twice. Importing a backup is unchanged and still lives in Settings. A library that is nonempty but uncertified is still refused rather than guessed at, which is the case that check was written for; a certified library whose store file has gone now says so instead of quietly starting you over on an empty one. - Fixed a diagnosed site being impossible to re-teach. Re-teaching a site whose stored rules were already flagged rolled the commit back every time, even when the new teaching was perfectly good — so the one action offered to repair the site could never be taken. A re-teach now commits unless it would introduce a *different* problem than the one already there, and says what that would be when it refuses. Repairing the site clears the flag; leaving it unchanged commits without pretending it was repaired.
docs/agent-notes/composed-teaching-ui.md Modified +17 / −5
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex a999449..67d038e 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -46,11 +46,18 @@ the strength of its name.  There is **no title-mode picker**. `ComposedTeachingViewModel` holds one chip row (`titleChips` + `titleRoles`, roles Work / chapter / ignore) and the rule form is-*derived* from it by `ComposedTeachingPresentation.inferredTitleRule`, per the-Req 8.6 table. Chips are whole delimiter-split segments by default; a selected+*derived* from it by `ComposedTeachingPresentation.inferredTitleRule`. Since+`teach-editor-authoring-gaps` that is a staged pipeline, not just the Req 8.6+table: structural guard → Req 3.1 reclassification (a fully-kept subdivided+segment counts as its whole-segment marking again) → the Req 3.2 edge-trim+branch (a partially-kept *edge* segment beside at least one whole-segment+marking authors a positional rule plus exact `trimPrefix`/`trimSuffix`) → the+pre-existing table over the reclassified chips. The seam the tests drive is+`inferenceOutcome(title:segments:chips:roles:)`, which also names the branch+taken. Chips are whole delimiter-split segments by default; a selected multi-part segment's scissors control replaces it in place with its parts.-`AsterismCore` was not touched: subdivided selection derives `.phrase` from two-character spans through the existing `PhrasePatternDeriver`.+Subdivided selection that doesn't trigger the edge-trim branch still derives+`.phrase` from two character spans through the existing `PhrasePatternDeriver`.  Two behaviours are easy to break and are covered by tests: @@ -67,7 +74,12 @@ Two behaviours are easy to break and are covered by tests: - **Re-teach fidelity.** `retainedTitleRule` + `titleEdited` still make the   commit reuse the Site's own definition verbatim until the title is edited;   `seedTitleEditor` now re-seeds the chip row (segment anchors inverted directly,-  whole-title trims and phrase literals located back onto character spans).+  whole-title trims and phrase literals located back onto character spans, and —+  since `teach-editor-authoring-gaps` — segment-form trims mapped back onto part+  boundaries via `TitleTrimApplicator.keptCharacterRange`, behind a faithfulness+  guard: if the stored trims don't reproduce on the current title or a kept+  boundary isn't part-aligned, the editor falls back to the default selection+  and raises the stored-title-rule notice instead of depicting a different rule).   `trimPrefix`/`workNamePreview` read the *effective* rule, so they show the   retained rule before any edit. 
specs/teach-editor-authoring-gaps/tasks.md Modified +35 / −34
diff --git a/specs/teach-editor-authoring-gaps/tasks.md b/specs/teach-editor-authoring-gaps/tasks.mdindex c3a0a6b..76b5cd9 100644--- a/specs/teach-editor-authoring-gaps/tasks.md+++ b/specs/teach-editor-authoring-gaps/tasks.md@@ -8,90 +8,91 @@ references:  ## URL editor state -- [ ] 1. bracketedIndices resolution helper <!-- id:gupztmg -->+- [x] 1. bracketedIndices resolution helper <!-- id:gupztmg -->   - Stream: 1   - Requirements: [1.3](requirements.md#1.3)   - References: Asterism/Asterism/Views/ComposedURLEditorState.swift, Asterism/AsterismTests/ComposedURLEditorStateTests.swift-  - [ ] 1.1. Write unit and differential property tests for bracketedIndices+  - [x] 1.1. Write unit and differential property tests for bracketedIndices     - ComposedURLEditorStateTests.     - Predicate is blank-blind (candidacy ignores blankness; core rejects a blank component only after the uniqueness check).     - Degenerate anchors never match: left .end, right .start.     - Differential property over generated component lists with blanks and repeated values, all anchor pairs: exactly one bracketed index with a non-blank component there ⇔ URLRuleApplicator.select succeeds and returns that component's value.     - Fixtures include [x,,x,y].-  - [ ] 1.2. Implement bracketedIndices in ComposedTeachingPresentation+  - [x] 1.2. Implement bracketedIndices in ComposedTeachingPresentation     - Pure nonisolated static in ComposedTeachingPresentation (ComposedURLEditorState.swift). -- [ ] 2. Slot-aware locator derivation (the default function) <!-- id:gupztmh -->+- [x] 2. Slot-aware locator derivation (the default function) <!-- id:gupztmh -->   - Blocked-by: gupztmg (bracketedIndices resolution helper)   - Stream: 1   - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [2.1](requirements.md#2.1)   - References: Asterism/Asterism/Views/ComposedURLEditorState.swift-  - [ ] 2.1. Write tests for the four default-function outcomes and the chapter-slot default+  - [x] 2.1. Write tests for the four default-function outcomes and the chapter-slot default     - Crafted paths: /series/{story}/{episode}, /a//b, /a//x//b, /a/x/a/y, single-component, trailing slash.     - Blank selected component → unauthorable.     - Sequence-slot whole last component defaults to (unanchored, .end); split-derived chapters excluded.     - Completion preference honours in-force sides.     - Totality property: every non-blank component yields exactly one outcome, and .locator outcomes resolve at the selected index.-  - [ ] 2.2. Implement SlotAnchoring, URLLocatorResolution, and the slot-aware urlLocator+  - [x] 2.2. Implement SlotAnchoring, URLLocatorResolution, and the slot-aware urlLocator     - Types AnchorSide, SlotAnchoring, URLLocatorResolution.     - Validity via URLRuleDefinition.work(locator:).validate(origin: .readerTaught, isCurrent: true) — the canDeclareSequenceOptional precedent.     - Preference order: Req 2.1 default / today's derivation with the immediate-neighbour left scan (Decision 4) → sole valid candidate → pending → unauthorable. -- [ ] 3. Transactional transitions and the anchoring lifecycle <!-- id:gupztmi -->-  - Blocked-by: gupztmh (Slot-aware locator derivation (the default function)), locator, default, locator, default+- [x] 3. Transactional transitions and the anchoring lifecycle <!-- id:gupztmi -->+  - Blocked-by: gupztmh (Slot-aware locator derivation (the default function))   - Stream: 1   - Requirements: [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.9](requirements.md#1.9)   - References: Asterism/Asterism/Views/ComposedURLEditorState.swift-  - [ ] 3.1. Write tests for refusal grounds, no-side-effect refusals, and the lifecycle table+  - [x] 3.1. Write tests for refusal grounds, no-side-effect refusals, and the lifecycle table     - Refusal grounds distinguished: non-resolving vs representation-rejected (both-unanchored on a single-component path is the latter — it resolves).     - Refused transitions leave state untouched, retained template included.     - Lifecycle table row by row including the edited column, sequence-slot same-index retention, split neutrality, and nil→component replacement.-  - [ ] 3.2. Implement selectComponent and chooseAnchor with the lifecycle rules+  - [x] 3.2. Implement selectComponent and chooseAnchor with the lifecycle rules     - selectComponent/chooseAnchor take in components: and return TransitionRefusal?; probe before mutating.     - chooseAnchor validity: the other side fixed to its in-force value where one exists, free otherwise. -- [ ] 4. Seeding and retained stored locators <!-- id:gupztmj -->+- [x] 4. Seeding and retained stored locators <!-- id:gupztmj -->   - Blocked-by: gupztmi (Transactional transitions and the anchoring lifecycle)   - Stream: 1   - Requirements: [1.7](requirements.md#1.7), [1.9](requirements.md#1.9)   - References: Asterism/Asterism/Views/ComposedURLEditorState.swift-  - [ ] 4.1. Write tests for anchor reflection, hidden anchors, and resolves-nowhere occupancy+  - [x] 4.1. Write tests for anchor reflection, hidden anchors, and resolves-nowhere occupancy     - Reflection only when the stored pair brackets the displayed chip exactly.     - Hidden-at-chip and resolves-nowhere retention for path AND query locators: a stored .workAndSequence must not narrow to .sequence when one locator seeds no chip (Q25).     - Orthogonal gestures (presence toggle, split-token tap) republish the retained locator byte-identically.     - seed resets edited.-  - [ ] 4.2. Implement the seed(from:in:) anchoring extension+  - [x] 4.2. Implement the seed(from:in:) anchoring extension     - Extends URLEditorState.seed(from:in:) to populate SlotAnchoring per slot. -- [ ] 5. rule(in:) composition and URLRuleStatus <!-- id:gupztmk -->+- [x] 5. rule(in:) composition and URLRuleStatus <!-- id:gupztmk -->   - Blocked-by: gupztmj (Seeding and retained stored locators)   - Stream: 1   - Requirements: [1.2](requirements.md#1.2), [1.5](requirements.md#1.5), [1.8](requirements.md#1.8)   - References: Asterism/Asterism/Views/ComposedURLEditorState.swift-  - [ ] 5.1. Write tests for slot-state statuses, write-back suppression, and the cross-slot clash+  - [x] 5.1. Write tests for slot-state statuses, write-back suppression, and the cross-slot clash     - Pending status names the missing side.     - No retainedTemplate/sequencePresence write-back on the pending and unauthorable paths.     - Cross-slot locator clash publishes .cleared, not .unauthorable (Q26 change-detector).     - Req 1.5: choosing anchoring in one slot leaves the other slot's locator bit-identical.     - canDeclareSequenceOptional: non-.locator resolution → false.-  - [ ] 5.2. Implement URLRuleStatus, URLRuleOutcome, and the slot-resolution rule(in:)+  - [x] 5.2. Implement URLRuleStatus, URLRuleOutcome, and the slot-resolution rule(in:)     - URLRuleStatus (cleared/valid/pending/unauthorable) and URLRuleOutcome(status, splitErrorMessage); rule(in:), combinedTemplateCore, canDeclareSequenceOptional move to slot resolution.     - Totality invariant: retained locator ∧ complete pair is unreachable — chooseAnchor drops the retained locator in the transition that completes the pair.  ## URL surface -- [ ] 6. ViewModel status plumbing <!-- id:gupztml -->+- [x] 6. ViewModel status plumbing <!-- id:gupztml -->   - Blocked-by: gupztmk (rule(in:) composition and URLRuleStatus)   - Stream: 1   - Requirements: [1.6](requirements.md#1.6), [1.8](requirements.md#1.8)   - References: Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift, Asterism/AsterismTests/ComposedTeachingViewModelTests.swift-  - [ ] 6.1. Write view-model tests for the commit gate, pending vs cleared, and load initialisation+  - [x] 6.1. Write view-model tests for the commit gate, pending vs cleared, and load initialisation     - ComposedTeachingViewModelTests: canConfirm false and urlAnchoringPendingMessage set while pending; pending vs cleared distinguished (chapterUnsourced, collapsed summary); preview generation early-returns unless valid/cleared; load() initialises urlRuleStatus alongside urlRuleDefinition.-  - [ ] 6.2. Implement updateURLRule, urlAnchoringPendingMessage, and the setURLRuleDefinition shim+  - [x] 6.2. Implement updateURLRule, urlAnchoringPendingMessage, and the setURLRuleDefinition shim     - updateURLRule(_:) preserves today's order: store status/definition → invalidatePreview → syncChapterRemedyDisclosure → generatePreviewIfValid.     - setURLRuleDefinition stays as a delegating shim — six test files call it. -- [ ] 7. Anchoring rows UI, notices, and describe relocation <!-- id:gupztmm -->+- [x] 7. Anchoring rows UI, notices, and describe relocation <!-- id:gupztmm -->+  - Chip taps route through selectComponent(_:in:) — the refusing entry point — instead of the unconditional select; without this call-site change Req 1.4's refusal is unreachable from the UI (design-critic review, finding 2).   - Move describe(left:)/describe(right:) to ComposedTeachingPresentation, view model delegates (Q23).   - Anchoring block below chipSections per selected path component: two LabeledContent Menu rows (Before it / After it) in the describe wording with a (default) annotation; a defaulted side shows the default value as current; a retained not-edited slot shows choose… beside the Req 1.7 notice, never the default values; an undecided side shows choose…   - Notice constants beside lastWorkNotice; ownership per the design table (transient view @State vs state-derived).@@ -100,15 +101,15 @@ references:   - Accessibility identifiers for rows and notices.   - Blocked-by: gupztml (ViewModel status plumbing)   - Stream: 1-  - Requirements: [1.1](requirements.md#1.1), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.10](requirements.md#1.10), [2.2](requirements.md#2.2)+  - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.10](requirements.md#1.10), [2.2](requirements.md#2.2)   - References: Asterism/Asterism/Views/ComposedURLDetailsEditor.swift, Asterism/Asterism/Views/ComposedTeachingView.swift, Asterism/Asterism/Views/ComposedTeachingPresentation.swift -- [ ] 8. URL end-to-end gesture tests and change-detector updates <!-- id:gupztmn -->+- [x] 8. URL end-to-end gesture tests and change-detector updates <!-- id:gupztmn -->   - Tapas: nudge → last-component tap into the sequence slot → (unanchored, .end) → applies to /series/{storyB}/{episode} (Req 2.3).   - m.fanfiction.net .literal(1) re-pin from a chapter-1 URL (1.11).   - Reopen from a chapter-2 URL → stored-rule notice, unedited recommit projects .unchanged (1.6).   - An anchoring change → the version projection reports the change and its re-keying (1.12).-  - Update the change-detector tests pinning the blank-skipping scan in ComposedURLEditorStateTests and OptionalSequenceThroughEditorTests, deliberately (Req 4.2 exceptions).+  - Update the change-detector tests pinning the blank-skipping scan in ComposedURLEditorStateTests and OptionalSequenceThroughEditorTests, deliberately (Req 4.2 exceptions). (Outcome: only ComposedURLEditorStateTests needed the update, in task 2; OptionalSequenceThroughEditorTests pinned nothing about the old scan and passes unchanged.)   - Blocked-by: gupztmm (Anchoring rows UI, notices, and describe relocation)   - Stream: 1   - Requirements: [1.6](requirements.md#1.6), [1.11](requirements.md#1.11), [1.12](requirements.md#1.12), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [4.2](requirements.md#4.2)@@ -116,11 +117,11 @@ references:  ## Title side -- [ ] 9. Title inference pipeline <!-- id:gupztmo -->+- [x] 9. Title inference pipeline <!-- id:gupztmo -->   - Stream: 2   - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5)   - References: Asterism/Asterism/Views/ComposedTeachingPresentation.swift-  - [ ] 9.1. Write ComposedTitleTrimInferenceTests for reclassification, trigger totality, and trim derivation+  - [x] 9.1. Write ComposedTitleTrimInferenceTests for reclassification, trigger totality, and trim derivation     - New app-target suite.     - Reclassification including punctuation-edged (Book 1) and the deliberate field-value change (Q18, Req 4.2 exception).     - Trigger totality: parameterized sweep over 1–4 segment titles with and without subdivision — every marking state lands in exactly one branch.@@ -129,43 +130,43 @@ references:     - Post-trim guards on TtH - Story -.     - Both-edges trims; chapter-role edge run; single-segment fallback.     - Stream: 2-  - [ ] 9.2. Implement the reclassify-then-infer pipeline with the edge-trim branch+  - [x] 9.2. Implement the reclassify-then-infer pipeline with the edge-trim branch     - inferredTitleRule pipeline: structural guard → reclassify → semantic guards → early branches → edge-trim branch (per-edge trims, re-tokenize, count/interior/edge guards, AnchorDerivation / TeachingValidator + PatternDeriver, failures fall through) → existing branches over reclassified chips.     - Stream: 2 -- [ ] 10. Title seeding with trims <!-- id:gupztmp -->+- [x] 10. Title seeding with trims <!-- id:gupztmp -->   - Blocked-by: gupztmo (Title inference pipeline)   - Stream: 2   - Requirements: [3.8](requirements.md#3.8)   - References: Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift-  - [ ] 10.1. Write seeding round-trip tests for the faithfulness guard, both branches+  - [x] 10.1. Write seeding round-trip tests for the faithfulness guard, both branches     - Guard passes → controls seed markings and trims; guard fails (trim absent from the current title; kept-range boundary not part-aligned) → default whole-title selection plus the stored-title-rule notice.     - Unedited recommit is the stored rule verbatim in both branches (retainedTitleRule/titleEdited).     - Stream: 2-  - [ ] 10.2. Implement the seedTitleEditor trims path and the stored-title-rule notice flag+  - [x] 10.2. Implement the seedTitleEditor trims path and the stored-title-rule notice flag     - seedTitleEditor .segment/.chapterlessSegment trims path: TitleTrimApplicator.keptCharacterRange, part-aligned boundary mapping, anchor inversion against the post-trim segment count, faithfulness guard comparing (definition, trimPrefix, trimSuffix).     - New view-model flag drives the notice.     - Stream: 2 -- [ ] 11. Trim caption and tapas acceptance <!-- id:gupztmq -->+- [x] 11. Trim caption and tapas acceptance <!-- id:gupztmq -->   - Blocked-by: gupztmp (Title seeding with trims)   - Stream: 2   - Requirements: [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.9](requirements.md#3.9)   - References: Asterism/Asterism/Views/ComposedTeachingView.swift, Asterism/AsterismTests/ComposedTeachingViewModelTests.swift-  - [ ] 11.1. Write the trim caption, gesture-driven tapas, and inference-soundness tests+  - [x] 11.1. Write the trim caption, gesture-driven tapas, and inference-soundness tests     - Caption content for segment-form and whole-title trims (Req 3.7).     - Gesture-driven tapas acceptance through subdivideSegment/cycleTitleRole — isAuthorable gating can make an authorable marking unreachable, so drive the real gestures; one rule parses both families to one Work with correct chapters (3.6, 3.9).     - Inference-soundness property: every authorable marking state's rule passes capabilities.validate and re-parses its own title with the marked values.     - Stream: 2-  - [ ] 11.2. Implement the trim caption row and stored-title notice rendering+  - [x] 11.2. Implement the trim caption row and stored-title notice rendering     - Caption row in ComposedTeachingView's title section, shown when effectiveTitleRule carries a trim (whole-title trims included).     - Render the stored-title notice from the 10.2 flag.     - Stream: 2  ## Integration -- [ ] 12. Full-suite verification <!-- id:gupztmr -->-  - make test-quick green; make test-core green with an empty Packages/AsterismCore diff (Reqs 4.1/4.3); no new compiler warnings; OptionalSequenceThroughEditorTests passes unchanged except the old-scan pins; no task touched stored-rule migration paths (4.1).+- [x] 12. Full-suite verification <!-- id:gupztmr -->+  - make test-quick green; make test-core green with an empty Packages/AsterismCore diff (Reqs 4.1/4.3) — waived once, after verification, for the folded-in Req 3.21 rename fix (Q31); no new compiler warnings; OptionalSequenceThroughEditorTests passes unchanged except the old-scan pins; no task touched stored-rule migration paths (4.1).   - Blocked-by: gupztmn (URL end-to-end gesture tests and change-detector updates), gupztmq (Trim caption and tapas acceptance)   - Stream: 1   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3)
specs/teach-editor-authoring-gaps/decision_log.md Modified +7 / −0
diff --git a/specs/teach-editor-authoring-gaps/decision_log.md b/specs/teach-editor-authoring-gaps/decision_log.mdindex 1b66c32..d72debe 100644--- a/specs/teach-editor-authoring-gaps/decision_log.md+++ b/specs/teach-editor-authoring-gaps/decision_log.md@@ -30,6 +30,13 @@ | Q24 | 2026-08-11 | Req 3.7's rule-summary half is satisfied by a new trim caption row in the title section, shown for any effective rule carrying trims | No title rule summary exists in the view today and `trimPrefix`/`trimSuffix` had no UI consumer — a pre-existing gap for whole-title trims that this closes as well (design review, N1) | | Q25 | 2026-08-11 | Retained-locator occupancy (Decision 6) covers every locator kind, query locators included | Req 1.8's last clause mandates that completing one slot leaves another slot's stored locator untouched, whatever its kind; the silent narrowing it replaces was the defect, not behaviour to preserve (focused design review, finding 2) | | Q26 | 2026-08-11 | The cross-slot locator clash keeps publishing a cleared status, as today | Upgrading it to unauthorable would block a commit today permits — a behaviour change outside Req 4.2's exceptions; the existing hint row remains the explanation (focused design review, finding 4) |+| Q27 | 2026-08-11 | A slot left `.pending` gates the commit even when the emitted rule form would not use that slot (live split → `.combined` while the sequence slot's anchoring is undecided) | Matches the design verbatim ("any slot `.pending` → status pending") and Req 4.2's third exception; the escape is clearing the URL selection. Accepted consciously rather than special-casing forms that discard a slot — task 7 renders the pending message and can revisit the wording if it reads wrongly there (design-critic review, finding 5) |+| Q28 | 2026-08-11 | The title property tests are recorded as knowingly partial: `triggerTotality` transcribes the implementation guard (stricter than Req 3.4's permitted fall-through, which no fixture fires) and the capability-gate half of `inferenceSoundness` is definitionally true of `isAuthorable` | The re-parse half of soundness and the branch-exclusivity sweep are the load-bearing halves and are real; recording the caveat beats leaving the design's testing bullets silently ticked (design-critic review, findings 12–13) |+| Q29 | 2026-08-11 | The design's "post-trim guards on `TtH - Story - `" testing bullet is knowingly unmet — no test fires the Req 3.4 re-tokenization guard | Both trims derive strictly inside a single segment's part boundaries, so a derived trim can never span a delimiter; the guard is unfireable via Req 3.2's trigger by construction and Req 3.4 itself calls it "a guard not expected to fire" (design-critic review, finding 14) |+| Q30 | 2026-08-11 | A stored rule whose locators seed no chip in either slot renders the Req 1.7 notice with no "Clear URL selection" affordance — accepted as-is | Pre-existing gating (`selectionHints` requires a live selection); the escape is any chip tap, which replaces the retained locator per the lifecycle table. Revisit only if a real site produces that state (URL surface review, finding 10) |+| Q31 | 2026-08-12 | The Req 3.21 Work-rename fix (inverted `workIdentity == nil` guards in `LibraryRepository+ComposedTeaching`/`+ReparseCapture`, plus the name-blind recalculation change detector) is folded into this branch, waiving the "no AsterismCore changes" non-goal for it | User decision after the first on-device tapas test: the stored trim was correct but the Work kept "Read …" because the rename code ran only for identity-*less* Works — a pre-existing defect this feature was the first to expose, small enough that a separate ticket was not worth the overhead. The non-goal stands for the feature itself; the core diff is exactly this fix (commit 276d93e) |+| Q32 | 2026-08-12 | `defaultFunctionTotality` is recorded as knowingly partial, like Q28: its `preferred` helper and branch ladder transcribe `preferredAnchoring` and the resolution ladder line for line, so it catches refactoring drift, not a misreading | The load-bearing half — `.locator` outcomes checked independently against core's `URLRuleApplicator.select` — is real, and the separate differential test asserts both directions of the iff against core (pre-push review, finding 6) |+| Q33 | 2026-08-12 | Remaining recorded coverage caveats after the pre-push review: Req 1.4's seeded-selection exemption holds structurally (seeding never routes through `selectComponent`) with no fixture; Req 2.1's split exclusion is tested via the work-slot arm only, not through a live split; Req 3.8's re-derivation-mismatch and post-trim segment-count guard arms are untested; and no reteach no-op version projection test carries trims | Each holds by construction or is exercised indirectly; recording them beats leaving the testing bullets silently ticked, per the Q28/Q29 pattern (pre-push review, findings 5, 7, 8) |  ## Decision 1: Gap 1 ships full per-side anchoring control, not only the narrow default 
specs/OVERVIEW.md Modified +1 / −1
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 213760c..fca7369 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -15,7 +15,7 @@ | [Optional Chapter Sequence](#optional-chapter-sequence) | 2026-08-06 | Done — all 11 tasks complete 2026-08-10; `make test-core` and `make test-quick` pass. Req 3.5's `www.` clause annotated as never-achievable (Q32) | Lets a combined URL rule declare its chapter sequence optional, so `tthfanfic.org`'s `/Story-28614/` and `/Story-28614-105/` resolve to one Work identity. A declared-optional rule reads the missing indicator as chapter 1 (Decision 7), which also gives that capture its siblings' identity-key shape. The reconciliation pass originally in scope is deferred (Q28) — the planner's existing collision issue plus Work merge covers the split pre-state, which has no instance. | | [Retire Migration Chain](#retire-migration-chain) | 2026-08-06 | Done — all 25 tasks complete 2026-08-09 and the device confirmation passed the same day (`Personal` installed over the real library, opened intact). `make test-core` (1,195 tests) and `make test-quick` pass, the Req 2.15 graph baseline matches, and one `make test-performance-m4` run exits 0 in ~20 minutes (verification-run.md) | Removes the unreachable V3→V4 and V4→V5 runtime migration machinery (T-2113, T-2114): bootstrap becomes a total function over the on-disk states that remain reachable, the schema surface states one version per name, and every store opener — app, extension, and tests — shares one schema declaration and one file layout. The live library's on-disk paths are frozen and pinned by test. | | [URL Locator Generalisation](#url-locator-generalisation) | 2026-08-08 | Done — re-teaching the four sites remains (prerequisites.md) | Adds an unanchored side to a path locator, so a taught rule stops pinning itself to the story it was taught from. Every rule in the library is pinned today: `tapas.io` resolves a URL identity on 1 capture of 69, `royalroad` on 9 of 15. Ships with the fix for `URLRulePattern.definition`, which fabricates a rule from bytes it cannot decode and can bake that fabrication into a backup (Decision 5). |-| [Teach Editor Authoring Gaps](#teach-editor-authoring-gaps) | 2026-08-10 | Planned | Closes the two authoring gaps the first real repair session surfaced (T-2135, Q25 of URL Locator Generalisation): a per-side anchoring choice for URL path locators — with the chapter-slot last-component default that fixes the chapter-nudge collision, and the blank-neighbour builder defect retired — and prefix/suffix trims on the positional segment title forms, so tapas' two title families parse with one rule. Editor-only; the representation already carries both capabilities end-to-end. |+| [Teach Editor Authoring Gaps](#teach-editor-authoring-gaps) | 2026-08-10 | Done — all 12 tasks complete 2026-08-12; `make test-core` and `make test-quick` green with zero new warnings (verified against a forced recompile). The AsterismCore non-goal was waived once for the folded-in Req 3.21 Work-rename fix (Q31); the tapas flow verified on-device | Closes the two authoring gaps the first real repair session surfaced (T-2135, Q25 of URL Locator Generalisation): a per-side anchoring choice for URL path locators — with the chapter-slot last-component default that fixes the chapter-nudge collision, and the blank-neighbour builder defect retired — and prefix/suffix trims on the positional segment title forms, so tapas' two title families parse with one rule. Editor-only; the representation already carries both capabilities end-to-end. |  --- 

Things to double-check

The URL-disclosure collapse refusal is silent. A collapse tap while an anchoring is pending is now ignored — deliberately, since the section is held open by urlDisclosureExpanded anyway and honouring the tap would only record state the view contradicts. But the reader gets no feedback that their tap did anything at all. The pending message is already on screen beside the confirm control, which is arguably the explanation; worth eyeballing on the device to decide whether the disclosure chevron needs to look non-interactive while pending.
MoveToModelTests flaked once during a full run. Seen a single time in one full-suite run on this branch. The test is untouched by the diff and passed on every re-run. Recorded here rather than chased — but if it recurs after the push it is worth investigating on its own, not as fallout from this branch.
Q27's pending gate over a rule form that discards the slot. A live split authoring .combined while the sequence slot's anchoring is undecided still refuses the commit, even though the emitted rule would not use that slot. Accepted on the record and it renders as the task-7 pending message, whose wording was written for the ordinary case. Worth reading that message in this specific state to confirm it does not sound like a bug.