asterism branch T-2156/rule-suggestion commits 34 files 49 touched lines +7416 / -0

Pre-push review: T-2156/rule-suggestion

Rule suggestion — the on-device Foundation Model proposes a title rule and a URL rule for an untaught hostname; the composed teaching editor opens pre-filled under a “Suggested” marker. Four implementation phases, each design-critic reviewed, plus prompt fixes and diagnostics from the first device run.

At a glance

  • 29 commits, ~7,400 added lines: a new package product (AsterismIntelligence), one Core read, four app files, a heavily extended teaching editor. No schema, migration or archive change.
  • Opt-out by absence: with no on-device model, or nothing verifying, the editor is exactly what it is today. Nothing is written until the reader saves.
  • Three correctness gates: spans must locate uniquely in the anchor capture, the assembled rules must be ones the editor itself would author, and they must project cleanly over every capture on the hostname.
  • Concurrency is the risk surface: a @MainActor coordinator, an actor for the attempt, a pure ledger, a task-group timeout, two-step pre-emption, voided in-flight records.
  • ~2,700 lines of new tests (743 for the ledger alone, two UI scenarios), against two acknowledged holes recorded in the phase-3 notes.
  • One prerequisite still open: the on-device latency spike. The bounds are provisional and a cold model call measured ~15 s on the host against a 10 s timeout.
  • Two deferred costs: ~2 SwiftData queries per hostname per foreground return (Q78), and up to three extra whole-hostname re-reads per attempt (Q79).

Verdict

Ready to push (one prerequisite still open)

All 33 requirements are met and pinned by tests; the pre-push reviews raised 18 findings, 12 fixed on the branch (derived editor state, sweep skips fully-dismissed hostnames, refusal reason from the ledger, budget guard before the candidate read, locator reuse, lazy log arguments, stub gated out of release, test gaps closed, docs/design brought current) and 6 deliberately skipped with a recorded reason (Q78, Q79, three nits). make test-core, make test-quick, both suggestion UI scenarios and a Personal simulator build are green. The one open item is the on-device latency spike from prerequisites.md: the five RuleSuggestionBounds are provisional and a cold host call measured ~15 s against the 10 s attempt timeout — the Personal build with logging is installed on the phone, measurements pending. It does not block pushing; it should be read before the constants are called final.

Review findings

18 raised · 13 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

Teaching Asterism a site means writing two rules by hand: how to pull a story's name out of a page title, and how to recognise the story in a page address. This branch has the phone's own language model propose both, so the reader reviews a rule instead of authoring one.

Open the teaching editor on a site you have not taught and the fields may already be filled in, under a small amber badge reading Suggested — review before saving. A Suggest rule button at the top of the editor asks for one at any time. Nothing is written until you press the normal Save; a Clear action puts the side back exactly as it was.

Terms worth knowing:

  • On-device model — Apple's small language model, running on the phone. No capture title or address leaves the device. It may be missing entirely, and the feature treats that as a non-event: no badge, no button, no message.
  • Taught rule — the rule a reader saves for a site. This feature proposes one; it never stores one.
  • Hostname — the site itself (example.com). Every piece of state here is per hostname.
  • Title rule / URL rule — two independent halves. Either can be suggested without the other.
  • "Suggested" marker — the badge naming a side as machine-filled. It vanishes the moment you change that side, and VoiceOver reads it as "Suggested title rule" or "Suggested URL rule".
  • Verification against captures — before anything is shown, the proposed rules are run over every capture on that site using the same projection the editor's preview uses. A rule that fails on any capture is discarded silently.

The design goal throughout is that the feature's absence costs nothing: a failed, slow or unavailable suggestion leaves the editor exactly as it is today.

Four layers, built outward:

  • New package product AsterismIntelligence (in Packages/AsterismCore, depends on AsterismCore, imports FoundationModels): the bounds constants, the @Generable RuleProposal, the model-client protocol plus a Foundation implementation and a scripted stub, ProposalLocator, SuggestionCorpus, and RuleSuggestionLedger. Core stays model-free, so the share extension never links the framework.
  • Core candidate read: ruleSuggestionCandidates(hostnames:) returns five facts per hostname — mode, both rule versions, capture count, newest capture — without traversing Site.entries. It answers both "what should the sweep attempt" and "has anything changed".
  • App assembler / suggester / coordinator: RuleSuggestionAssembler turns located spans into rules by driving the editor's own machinery (chip inference; a headless URLEditorState), so a suggestion can never be a rule the editor cannot depict. RuleSuggester is an actor: basis read → corpus → model → locate → assemble → verify. RuleSuggestionCoordinator is @MainActor @Observable and owns the ledger, the attempt Task and the waiters.
  • View model / view: seedSuggestionIfAvailable() at the end of load(), applySuggestion(_:origin:), markers, clear action, Suggest row.

The ledger is a pure state machine — a Sendable, Equatable struct with no clock, no Task, no ProcessInfo; environment facts arrive as a parameter. Every transition is a host unit test.

Pre-emption is a two-step protocol (Q54): start returns .preempt without swapping the record; the coordinator cancels and awaits the loser, which settles itself on the way out, then asks again.

Substrings, not offsets (Decision 1): the model returns copied text and code locates it, accepting only a unique occurrence. Three verification ladders (Q44/Q48): short-circuit when neither chapter nor sequence text exists, then try title+url, title-only with the stored URL rule, url-only with the stored title rule.

Trade-offs: one coarse invalidation hook after every refresh (saving one side re-attempts the other later, Q43); verification re-reads the library up to three times per attempt (Q79); the candidate read is ~2 queries per hostname per foreground return (Q78); nothing persists across launches.

Timeout as a task group. RuleSuggester.attempt races steps 3–6 against a Task.sleep inside one withThrowingTaskGroup; the winner decides, cancelAll() follows, and the loser is awaited on scope exit, so AttemptTimeout is thrown only once the work has actually stopped. The bound is therefore a stop request, not a hard deadline.

Voided in-flight records. invalidate, reconcile and memoryWarning set InFlight.voided rather than clearing the record: a later settlement still charges the budget and then discards its result, and a start against a voided record returns .preempt, never .attach — attaching would hand the caller an answer settle is bound to throw away.

Sweep generation token (Q64). permittedSweep (stop signal) and runningSweep (single-instance guard) are separate fields carrying the same integer. One flag for both let a re-activation during a stopped-but-live sweep pass the guard, refuse every candidate, then end the older sweep from under it — silently skipping that activation's sweep.

Derived editor state (Q80). titleSuggestionApplied is appliedTitle != nil; suggestionReady is computed from the hold, the applied payload and the dismissals. The earlier latched flags went stale after a clear and lit for sides dismissed earlier in the run.

Halving (Q53). corpus.halved() returns an optional and yields nil at the anchor-alone floor; nil terminates the retry loop, so no counter exists. isContextWindowOverflow sits on the client protocol (default false) so the suggester never imports FoundationModels.

Prompt. Greedy sampling keeps a corpus reproducible; RuleProposal is four Strings, empty meaning "none" (Q39). Examples A–C and the Q74 bare-number hint were both added after real captures failed.

Architecture. project.pbxproj adds the product to the app and AsterismTests only; the extension phase is untouched — held by inspection, not by a build check. The coordinator is main-actor, the suggester an actor, which required nonisolated on the pure presentation helpers and their value types, because even a synthesized == is main-actor bound under this target's default isolation.

Risks. Cold model latency measured ~15 s on the host against a 10 s timeout; the device latency spike is still open and all five bounds are provisional.

Important changes — detailed

RuleSuggestionLedger — the whole feature's state machine

Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift

Why it matters. Every rule about when an attempt may run, what it costs, and what survives a change is enforced here, in one pure value type.

What to look at. start(hostname:origin:fingerprint:environment:) 228-275; settle 277-310; beginSweep/isSweeping/endSweep 199-221; invalidate/reconcile/memoryWarning 333-368

Takeaway. Keeping the clock, the Task and the environment outside the type is what makes 743 lines of transition tests possible on the host.
Rationale. Design ledger section; Q54 (two-step pre-emption), Q64 (generation token), Q28/Q45 (timeout attempted, cancel unattempted), Q58.

RuleSuggester.attempt — timeout task group and the three verification ladders

Asterism/Asterism/RuleSuggestion/RuleSuggester.swift

Why it matters. The only place a model answer becomes a rule the reader might save; both the timeout bound and the Req 3.4 correctness gate live in it.

What to look at. attempt(hostname:) 71-117 (withThrowingTaskGroup 96-109); run(...) 126-251 (Q44 short-circuit 190-194, ladders 227-250); propose halving loop 256-280; URL component locator 365-394

Takeaway. The timeout awaits the loser on scope exit, so it stops work rather than abandoning it — but work ignoring cancellation still runs past 10 s.
Rationale. Design attempt pipeline steps 1-7; Q44, Q48, Q53, Q45/Q63.

RuleSuggestionCoordinator — sweep, pre-emption, reconcile, delivery

Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift

Why it matters. The single delivery channel for on-open and on-request, and the only owner of the attempt Task — cancelling a caller must never cancel the work.

What to look at. runSweep() 132-182; suggestion(for:origin:candidate:) 226-286 (held-first 230, openRefusal 239, start loop 257-285); beginAttempt/settle 290-328; reconcile() 186-204

Takeaway. held-first return, refuse-before-read and awaiting the pre-empted task stack three guards in one method; the while-true retry has no iteration cap.
Rationale. Design coordinator section; Q40 (MainActor + actor split), Q65 (refuse before the read), Q57, Q31.

FoundationRuleSuggestionModelClient — the prompt is the feature's accuracy

Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift

Why it matters. Two of this branch's later commits are prompt fixes for real captures that failed. The instructions text is load-bearing and barely validated.

What to look at. instructions 96-151 (Examples A-C, Q74 hint 104-107); propose 37-44; generationOptions 49; isContextWindowOverflow/describe 57-88

Takeaway. One LanguageModelSession per call puts model load inside every attempt's measured phase — the likely cause of the cold-latency risk.
Rationale. Q73 (third example, after the Tappytoon capture), Q74 (decimal chapter hint), Q39, Q56.

ComposedTeachingViewModel — apply, retire, derive

Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift

Why it matters. 477 changed lines in the app's most intricate view model: every marker, dismissal and rollback rule, with subtle snapshot semantics.

What to look at. suggestionReady 172-180; requestSuggestion 951; clearSuggestedSide 974; applySuggestion 984; seedSuggestionIfAvailable 1011; applyTitleSuggestion 1091; retireSuggestion 1159; dismissDivergentlySavedSides 1206

Takeaway. A failed re-apply rolls back to the state at call start, not the pre-suggestion baseline — the latter would silently undo a suggestion already accepted.
Rationale. Q42 (clear restores the snapshot), Q47/Q69 (URL edit detection), Q80 (derived state), Q33.

LibraryRepository+RuleSuggestion — the candidate read and its cost

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift

Why it matters. The one new Core surface, and the branch's only recurring per-foreground cost. Q78 explicitly defers fixing it.

What to look at. ruleSuggestionCandidates(hostnames:) 27-59 (empty short-circuit 30, winnersByHostname 45, unknown-mode omission 53); ruleSuggestionCandidate 65-82 (fetchCount + fetchLimit-1 per site)

Takeaway. Two scoped Entry queries per Site per whole-store read; the current mitigation is skipping the read once the budget is spent.
Rationale. Q37, Q59, Q60/Q61 (2N chosen over a whole-store Entry pass), Q78 (deferred).

project.pbxproj — Req 4.4 is enforced by this file alone

Asterism/Asterism.xcodeproj/project.pbxproj

Why it matters. The requirement that the share extension never links FoundationModels has no automated check; confirm the product landed in exactly two frameworks phases.

What to look at. PBXFrameworksBuildPhase 113-151: added to the app and AsterismTests phases; A10000000000000000000006 (AsterismShareExtension) unchanged

Takeaway. A future 'add the framework to all targets' click would break a stated requirement with no test failing.
Rationale. Q62 (tests link it, the extension does not, checked by grep); Decision 2.

Key decisions

The model returns substrings; code locates them (Decision 1)

The @Generable output carries each field's text, copied verbatim, and ProposalLocator re-finds it, accepting only a unique occurrence — Apple advises against asking a 3B model to count. Rejected: character offsets, @Guide(.anyOf(...)), dynamic per-hostname schemas. Cost: a repeated substring, or whitespace drift, loses the field.

Model-facing code lives in a new AsterismIntelligence target (Decision 2)

A second library product importing FoundationModels, because the extension links Core and must never link the model framework (Req 4.4), and the package builds on the macOS 26 host so the pure parts get make test-core coverage. Cost: assembly stays app-side, so no package test covers a full attempt.

The held artefact is a rule, not spans (Q14)

A background computation cannot know which capture the editor will open from, and spans only mean something against one capture. A held rule seeds through the existing stored-rule path, cannot-depict fallback included.

Two-step pre-emption enforced by protocol (Q54)

start returns .preempt without swapping the record; the coordinator cancels that task and awaits its termination — it settles .cancelled itself, holding the elapsed clock — then asks again. A value type can enforce single-flight no other way, and a second settle would double-charge the budget.

One reconcile after every refresh, not per-mutation hooks (Q43)

One reconcile() comparing candidate-row fingerprints. Per-site hooks missed entry delete, articles toggle, curation and CloudKit arrivals. Accepted cost: saving one side invalidates the other side's still-valid suggestion.

Sweep bounded to 3 hostnames, 60 s per run, gated on power and heat (Q17)

didBecomeActive fires on every foreground return, so the draft's 25 hostnames was minutes of Neural Engine work per return. Positions 1–3 carry nearly all the value, and a time budget survives a latency change where a count does not.

Verification is one whole-hostname projection per candidate set (Q35, Q48)

The projection already applies both rules to every entry and reports the three things Req 3.4 names. A single-side set fills the other slot with the stored rule, because that is what the reader would save. Accepted: a URL-only suggestion cannot verify where the stored title rule already fails an entry.

Sweep state is a stop signal plus a generation-keyed guard (Q64)

One flag meaning both "may start" and "is running" let a re-activation during a stopped-but-live sweep pass the guard, refuse every candidate, then end the older sweep from under it — silently skipping that activation's sweep.

The editor's suggestion state is derived, not latched (Q80)

titleSuggestionApplied is applied != nil; suggestionReady is computed from the hold, the applied payload and the dismissals. The latched version went stale after a clear and lit for sides dismissed earlier in the run.

An open behind a request for another hostname is refused, not queued (Q55)

An open never pre-empts the reader's own request, leaving refusal as the only option. Consequence: that editor opens without a suggestion and Req 5.7's "a computation started" does not hold until the next sweep.

Diagnostic logging exists because every drop point is silent (Q76)

Req 4.1 forbids telling the reader anything, which made the first device failure undiagnosable. Reasons and durations log publicly, reader content only under DEBUG, and describe(_:) sits on the client protocol so the Foundation client can name the GenerationError case.

Review findings

SeverityAreaFindingResolution
majorComposedTeachingViewModel suggestionReadysuggestionReady was latched (set by noteUnappliedSides, cleared only in requestSuggestion) so it went stale after clearSuggestedSide and lit for a side dismissed in an earlier session — against Q68.Made a computed property: held sides minus applied minus dismissed; noteUnappliedSides deleted; two new view-model tests.
majorBackground sweep vs dismissalNeither the sweep filter nor the ledger's .background arm checked both-sides-dismissed; invalidate keeps dismissals, so a corpus change re-spent a slot and up to 10 s of budget on a hostname the reader rejected on both sides.RuleSuggestionLedger.isFullyDismissed(_:) checked in start(.background) and the sweep filter; ledger + coordinator tests.
majorCoordinator refusalReasonCoordinator re-derived the refusal reason from ledger state and could mis-report (e.g. 'both sides dismissed' for a background refusal that never checked dismissal).AttemptStart.refuse(RefusalReason) carries the reason the ledger actually applied; coordinator helper removed.
majorSweep pays candidate read when budget spentactivationSweep read all candidates before checking budgetExhausted, so every activation after the 60 s budget was spent paid the whole-store read for nothing.Guard on model availability and budget before ruleSuggestionCandidates(hostnames: nil); test asserts no read once exhausted.
majorWhole-store candidate read costruleSuggestionCandidates(nil) pays a count fetch and a fetchLimit-1 fetch per site before the coordinator's eligibility filter — ~2 queries per hostname per foreground return.Deferred (Q78): fixing it well changes the documented nil = all sites contract or needs a batched Entry fetch; sweep now skips the read entirely once the budget is spent.
majorRedundant applied flagstitleSuggestionApplied/urlSuggestionApplied always set and cleared together with appliedTitleSuggestion/appliedURLSuggestion — four properties for two facts across five mutation sites.Flags are now computed from the payloads; public names unchanged.
majorRetire-marker block duplicated ×3clearSuggestedSide, updateURLRule and commitTitleEdit each hand-wrote 'clear snapshot, clear applied payload, dismiss side'.Extracted retireSuggestion(_:restoringSnapshot:).
minorRuleSuggester URL locatoroccurrences(of:in:) re-implemented ProposalLocator.locate's Character scan and then called locate again on the same string.ProposalLocator.place(_:in:) -> .unique/.absent/.ambiguous; locate built on it; suggester uses it; four new locator tests.
minorVerification re-projects held basisverifies() calls library.projectComposedTeaching up to three times per attempt after already reading the basis; the pure planner could run over the held basis.Kept (Q79): the re-read verifies against captures landing mid-attempt, and the mock projection handler is the only seam six suggester tests use. Bounded by three hostnames per activation.
minorLog argument eagernessRuleSuggestionLog.note/failure took String, so interpolations (describe(proposal), String(describing: error), refusal state) were built even when the category was disabled.@autoclosure parameters with an isEnabled early-return; SuggestionFailure.describe centralises the error string used by the log, the protocol default and the Foundation client; CandidateSet enum replaces three string literals.
minorStub client in shipping moduleStubRuleSuggestionModelClient and its Mutex Recorder were public in AsterismIntelligence and compiled into release builds.Gated behind #if DEBUG || ASTERISM_PERFORMANCE_TESTING (and UITestLaunchSupport.suggestionClient likewise); Personal build verified.
minorTest gapsNo tests for suggester-level overlap drops (chapter⊂work, sequence⊂identity), UITestLaunchSupport.suggestionClient mapping, RuleSuggestionLog.milliseconds; five coordinator tests raced 5 ms sleeps against 30–50 ms stub delays.Tests added; the five sleeps replaced by the file's waitForAttempts(1) arrival signal.
minorDesign text staledesign.md still described inFlight as a tuple, sweepActive as a flag, two worked examples, AttemptTimeout without payload, the protocol without isContextWindowOverflow/describe, and the Suggest control beside Save.Amended in commit 719b870 (Q54, Q56, Q63, Q64, Q73–Q76).
minorDivergences without decision-log entryLogging layer + describe(_:) seam, suggestionAutoApplyWindow test seam, composed-suggest-row identifier were undocumented.Q76, Q77 added; identifier added to the design table.
minorDocsCLAUDE.md silent on the second package product and the live Apple Intelligence test in make test-core; v2 plan still said item 3 is next to start while OVERVIEW said Done; four CHANGELOG entries for one feature, three 'no user-visible change'; prerequisites still prescribed an instrumented device test.CLAUDE.md bullets added; v2 plan marked implemented/PR pending; changelog folded into one entry; prerequisites point at the Console-log route.
nitCoordinator holds model and suggesterCoordinator keeps the model client only for availability(); the suggester owns the same client.Skipped — touches the coordinator init used across tests for a small gain.
nitModelAvailability.unavailable(reason: String)Closed four-case set carried as a free string.Skipped — logging-only value; enum would be churn without a consumer.
nitDuration→ms helper, notice-caption Label copies, TitleRuleSuggestion↔InferredTitleRule conversionsThird hand-rolled Duration conversion; three more amber caption Labels in a file with eleven; field-by-field conversions between identical shapes across the module boundary.Skipped — cosmetic; the module boundary justifies the separate type.

Per-file diffs

Click to expand.

Asterism/Asterism.xcodeproj/project.pbxproj Modified +16 / -0
diff --git a/Asterism/Asterism.xcodeproj/project.pbxproj b/Asterism/Asterism.xcodeproj/project.pbxprojindex 4a0a532..7ef2f6e 100644--- a/Asterism/Asterism.xcodeproj/project.pbxproj+++ b/Asterism/Asterism.xcodeproj/project.pbxproj@@ -15,6 +15,8 @@ 		A10000000000000000000019 /* ConstellationKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001C /* ConstellationKit */; }; 		A1000000000000000000001A /* ConstellationKit in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001D /* ConstellationKit */; }; 		A1000000000000000000001E /* AsterismCore in Frameworks */ = {isa = PBXBuildFile; productRef = A1000000000000000000001F /* AsterismCore */; };+		A10000000000000000000020 /* AsterismIntelligence in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000022 /* AsterismIntelligence */; };+		A10000000000000000000021 /* AsterismIntelligence in Frameworks */ = {isa = PBXBuildFile; productRef = A10000000000000000000023 /* AsterismIntelligence */; }; /* End PBXBuildFile section */  /* Begin PBXContainerItemProxy section */@@ -124,6 +126,7 @@ 			files = ( 				A10000000000000000000001 /* AsterismCore in Frameworks */, 				A10000000000000000000018 /* ConstellationKit in Frameworks */,+				A10000000000000000000020 /* AsterismIntelligence in Frameworks */, 			); 			runOnlyForDeploymentPostprocessing = 0; 		};@@ -133,6 +136,7 @@ 			files = ( 				A10000000000000000000014 /* AsterismCore in Frameworks */, 				A1000000000000000000001A /* ConstellationKit in Frameworks */,+				A10000000000000000000021 /* AsterismIntelligence in Frameworks */, 			); 			runOnlyForDeploymentPostprocessing = 0; 		};@@ -218,6 +222,7 @@ 			name = Asterism; 			packageProductDependencies = ( 				A10000000000000000000011 /* AsterismCore */,+				A10000000000000000000022 /* AsterismIntelligence */, 				A1000000000000000000001B /* ConstellationKit */, 			); 			productName = Asterism;@@ -243,6 +248,7 @@ 			name = AsterismTests; 			packageProductDependencies = ( 				A10000000000000000000013 /* AsterismCore */,+				A10000000000000000000023 /* AsterismIntelligence */, 				A1000000000000000000001D /* ConstellationKit */, 			); 			productName = AsterismTests;@@ -922,6 +928,16 @@ 			package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */; 			productName = AsterismCore; 		};+		A10000000000000000000022 /* AsterismIntelligence */ = {+			isa = XCSwiftPackageProductDependency;+			package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */;+			productName = AsterismIntelligence;+		};+		A10000000000000000000023 /* AsterismIntelligence */ = {+			isa = XCSwiftPackageProductDependency;+			package = A10000000000000000000010 /* XCLocalSwiftPackageReference "../Packages/AsterismCore" */;+			productName = AsterismIntelligence;+		}; /* End XCSwiftPackageProductDependency section */ 	}; 	rootObject = D4A9C78C30091593004199A5 /* Project object */;
Asterism/Asterism/ContentView.swift Modified +10 / -0
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 2f2a5f5..69c95cc 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -202,6 +202,16 @@ struct ContentView: View {         .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in             Task { await model.handleActivation() }         }+        // `rule-suggestion` Req 5.3: the background sweep stops with the+        // foreground. On-open and on-request work is the reader's and continues.+        .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in+            model.suggestions?.resignActive()+        }+        // Req 5.6: held suggestions are cheap to recompute and are the first+        // thing to go under memory pressure.+        .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in+            model.suggestions?.memoryWarning()+        }     }      private var readyContent: some View {
Asterism/Asterism/RuleSuggestion/RuleSuggester.swift Added +420 / -0
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggester.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggester.swiftnew file mode 100644index 0000000..5d97be8--- /dev/null+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggester.swift@@ -0,0 +1,420 @@+import AsterismCore+import AsterismIntelligence+import Foundation++/// One suggestion attempt for one hostname. The seam the coordinator holds, so+/// its tests need no model and no library.+protocol RuleSuggesting: Sendable {+    /// Steps 1–7 of the design's attempt pipeline.+    ///+    /// Throws `AttemptTimeout` when the work outlived its bound, and+    /// `CancellationError` when the system cancelled it. Everything else — an+    /// unreadable basis, a model failure, a proposal nothing survives — is a+    /// settled attempt with `nil`, because Req 4.2 forbids any of it reaching+    /// the reader.+    func attempt(hostname: String) async throws -> (suggestion: RuleSuggestion?, modelPhase: Duration)+}++/// The attempt pipeline: read the basis, ask the model, locate what it copied,+/// assemble the rules, verify them against the whole hostname.+///+/// An actor, and deliberately not the main one: the model call and up to four+/// projections must never run on the main actor (Req 5.1). It owns no state+/// beyond its two dependencies — the ledger, the budget and the in-flight+/// `Task` all live in the coordinator.+actor RuleSuggester: RuleSuggesting {+    private let library: any LibraryProviding+    private let model: any RuleSuggestionModelClient+    /// Injected so the timeout path is testable in milliseconds rather than in+    /// the ten seconds Req 5.10 sets.+    private let timeout: Duration++    /// Production's initializer: Req 5.10's bound and nothing else to choose.+    ///+    /// **Main-actor isolated, and cannot be made otherwise here.** Under this+    /// target's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` *every*+    /// initializer below is `@MainActor` — delegating or not, and whether or+    /// not the bound is a default argument (measured: a nonisolated call site+    /// warns "call to main actor-isolated initializer … in a synchronous+    /// nonisolated context" for the three-argument initializer just as much as+    /// for this one). `nonisolated` cannot buy the way out: the compiler+    /// rejects it on an actor's synchronous initializer outright, as+    /// "'nonisolated' on an actor's synchronous initializer is invalid".+    ///+    /// That costs nothing: the suggester is constructed at the app's wiring+    /// point, which is main-actor anyway, and every *use* of it — `attempt` —+    /// is on the actor. Only construction is pinned.+    init(library: any LibraryProviding, model: any RuleSuggestionModelClient) {+        self.init(library: library, model: model,+                  timeout: RuleSuggestionBounds.attemptTimeout)+    }++    /// The timeout is injected here so its path is testable in milliseconds+    /// rather than in the ten seconds Req 5.10 sets.+    init(+        library: any LibraryProviding,+        model: any RuleSuggestionModelClient,+        timeout: Duration+    ) {+        self.library = library+        self.model = model+        self.timeout = timeout+    }++    /// The projection that reads the basis: the same whole-title, no-URL request+    /// `ComposedTeachingViewModel.load()` opens with, so the suggester sees the+    /// hostname exactly as the editor will.+    private nonisolated static let basisRequest = ComposedTeachingRequest(+        titleDefinition: .wholeTitle, trimPrefix: nil, trimSuffix: nil,+        urlDefinition: nil, acknowledgeUnsettled: false, permitsArticlesConversion: false)++    func attempt(+        hostname: String+    ) async throws -> (suggestion: RuleSuggestion?, modelPhase: Duration) {+        // Step 1. An articles-mode Site makes this throw, and so does an+        // unreadable library; both settle the attempt with nothing to show.+        let basis: ComposedTeachingBasis+        do {+            basis = try await library+                .projectComposedTeaching(hostname: hostname, request: Self.basisRequest).basis+        } catch {+            if error is CancellationError { throw error }+            RuleSuggestionLog.failure(+                "\(hostname): basis projection failed — \(RuleSuggestionLog.describe(error))")+            return (nil, .zero)+        }++        // Step 2.+        guard let corpus = SuggestionCorpus.make(from: basis.entries, hostname: hostname) else {+            RuleSuggestionLog.note("\(hostname): empty corpus — the basis holds no captures")+            return (nil, .zero)+        }+        RuleSuggestionLog.note("\(hostname): corpus of \(corpus.examples.count) captures")++        // Steps 3–6 under the timeout, measured from the model request.+        let started = ContinuousClock.now+        let outcome = try await withThrowingTaskGroup(of: PipelineOutcome.self) { group in+            group.addTask {+                .settled(try await self.run(corpus: corpus, basis: basis, hostname: hostname))+            }+            group.addTask {+                try await Task.sleep(for: self.timeout)+                return .timedOut+            }+            guard let first = try await group.next() else { throw CancellationError() }+            // Whichever lost is cancelled here and awaited on scope exit, so a+            // timeout is thrown only once the work has actually stopped.+            group.cancelAll()+            return first+        }++        switch outcome {+        case .settled(let suggestion):+            return (suggestion, started.duration(to: .now))+        case .timedOut:+            throw AttemptTimeout(modelPhase: started.duration(to: .now))+        }+    }++    private nonisolated enum PipelineOutcome: Sendable {+        case settled(RuleSuggestion?)+        case timedOut+    }++    // MARK: - Steps 3–6++    private func run(+        corpus: SuggestionCorpus, basis: ComposedTeachingBasis, hostname: String+    ) async throws -> RuleSuggestion? {+        // Step 3.+        guard let proposal = try await propose(corpus) else { return nil }++        // Step 4.+        let anchor = corpus.anchor+        let titleWork = ProposalLocator.locate(proposal.workName, in: anchor.title)+        if titleWork == nil {+            RuleSuggestionLog.note(+                "\(hostname): workName not located exactly once in the anchor title",+                content: "workName=\"\(proposal.workName)\" title=\"\(anchor.title)\"")+        }+        // The optional field dies with the required one on its own side: a+        // chapter span with no Work span authors nothing (Req 3.1, Q49).+        var titleChapter: Range<Int>?+        if let titleWork {+            titleChapter = ProposalLocator.locate(proposal.chapterText, in: anchor.title)+            if titleChapter == nil, !proposal.chapterText.isEmpty {+                RuleSuggestionLog.note(+                    "\(hostname): chapterText not located exactly once in the anchor title",+                    content: "chapterText=\"\(proposal.chapterText)\" title=\"\(anchor.title)\"")+            }+            if let chapter = titleChapter, chapter.overlaps(titleWork) {+                RuleSuggestionLog.note(+                    "\(hostname): chapterText span overlaps the workName span; chapter dropped")+                titleChapter = nil+            }+        }++        let components = try? RawURLRuleParser.parse(ExactScalarString(anchor.rawURL))+        var urlIdentity: LocatedURLSpan?+        var urlSequence: LocatedURLSpan?+        if let components {+            urlIdentity = Self.locate(proposal.urlWorkIdentity, in: components)+            if urlIdentity == nil {+                RuleSuggestionLog.note(+                    "\(hostname): urlWorkIdentity not located in exactly one URL component",+                    content: "urlWorkIdentity=\"\(proposal.urlWorkIdentity)\" url=\"\(anchor.rawURL)\"")+            }+            if urlIdentity != nil {+                urlSequence = Self.locate(proposal.urlSequenceText, in: components)+                if urlSequence == nil, !proposal.urlSequenceText.isEmpty {+                    RuleSuggestionLog.note(+                        "\(hostname): urlSequenceText not located in exactly one URL component",+                        content: "urlSequenceText=\"\(proposal.urlSequenceText)\" url=\"\(anchor.rawURL)\"")+                }+            }+            if let identity = urlIdentity, let sequence = urlSequence,+               identity.selection == sequence.selection, identity.range.overlaps(sequence.range) {+                RuleSuggestionLog.note(+                    "\(hostname): urlSequenceText span overlaps urlWorkIdentity's; sequence dropped")+                urlSequence = nil+            }+        } else {+            RuleSuggestionLog.note(+                "\(hostname): the anchor URL did not parse; no URL side",+                content: "url=\"\(anchor.rawURL)\"")+        }++        // Short-circuit (Q44): with neither a chapter nor a sequence the+        // projection sets `requiresUnsettledAcknowledgment`, which Req 3.4+        // rejects — so the up-to-three projections would all fail.+        guard titleChapter != nil || urlSequence != nil else {+            RuleSuggestionLog.note(+                "\(hostname): no chapter span and no sequence span; chapters would be unsettled")+            return nil+        }++        // Step 5.+        let title = titleWork.flatMap {+            RuleSuggestionAssembler.titleRule(+                anchorTitle: anchor.title, workSpan: $0, chapterSpan: titleChapter)+        }+        let url = components.flatMap { parsed in+            urlIdentity.flatMap { identity in+                RuleSuggestionAssembler.urlRule(+                    components: parsed, identity: (identity.selection, identity.range),+                    sequence: urlSequence.map { ($0.selection, $0.range) })+            }+        }+        if titleWork != nil, title == nil {+            RuleSuggestionLog.note(+                "\(hostname): the editor would author no title rule from those spans")+        }+        if urlIdentity != nil, url == nil {+            RuleSuggestionLog.note(+                "\(hostname): the editor would author no URL rule from those selections")+        }++        // A title rule equal to the untaught default is worth nothing on its+        // own — it would badge the state the editor already opens in — so it+        // survives only alongside a URL side.+        let soloTitle = title.flatMap { Self.isUntaughtDefault($0) ? nil : $0 }+        guard url != nil || soloTitle != nil else {+            RuleSuggestionLog.note(+                "\(hostname): nothing to offer — no URL rule, and the title rule is the untaught default")+            return nil+        }++        // Step 6, in the design's order. The first passing set wins.+        if let title, let url,+           try await verifies(+            hostname: hostname, candidateSet: .titleAndURL, basis: basis,+            request: Self.request(title: title, url: url)) {+            RuleSuggestionLog.note("\(hostname): verified \(CandidateSet.titleAndURL.rawValue)")+            return RuleSuggestion(hostname: hostname, title: title, url: url)+        }+        if let soloTitle,+           try await verifies(+            hostname: hostname, candidateSet: .titleOnly, basis: basis,+            request: Self.request(title: soloTitle, url: basis.currentURLRule?.definition)) {+            RuleSuggestionLog.note("\(hostname): verified \(CandidateSet.titleOnly.rawValue)")+            return RuleSuggestion(hostname: hostname, title: soloTitle, url: nil)+        }+        if let url,+           try await verifies(+            hostname: hostname, candidateSet: .urlOnly, basis: basis,+            request: Self.request(title: Self.storedTitle(basis), url: url)) {+            RuleSuggestionLog.note("\(hostname): verified \(CandidateSet.urlOnly.rawValue)")+            return RuleSuggestion(hostname: hostname, title: nil, url: url)+        }+        RuleSuggestionLog.note("\(hostname): no candidate set verified")+        return nil+    }++    /// Step 3: the model, halving the corpus on an overflow rather than giving+    /// up on the hostname (Req 3.7). `halved()` returns nil at the anchor-alone+    /// floor, which is what ends the loop (Q53).+    private func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal? {+        var current: SuggestionCorpus? = corpus+        while let attempted = current {+            do {+                RuleSuggestionLog.note(+                    "\(attempted.hostname): model call, \(attempted.examples.count) captures")+                let proposal = try await model.propose(attempted)+                RuleSuggestionLog.note(+                    "\(attempted.hostname): model proposed", content: Self.describe(proposal))+                return proposal+            } catch {+                if error is CancellationError { throw error }+                guard model.isContextWindowOverflow(error) else {+                    RuleSuggestionLog.failure(+                        "\(attempted.hostname): model call failed — \(model.describe(error))")+                    return nil+                }+                RuleSuggestionLog.note(+                    "\(attempted.hostname): context window overflow at \(attempted.examples.count) captures; halving")+                current = attempted.halved()+            }+        }+        RuleSuggestionLog.note("\(corpus.hostname): corpus cannot shrink further; no proposal")+        return nil+    }++    private nonisolated static func describe(_ proposal: RuleProposal) -> String {+        """+        workName="\(proposal.workName)" chapterText="\(proposal.chapterText)" \+        urlWorkIdentity="\(proposal.urlWorkIdentity)" urlSequenceText="\(proposal.urlSequenceText)"+        """+    }++    /// The three sets Req 3.4 tries, in order. The raw values are what the log+    /// carries, which is the only reason they are strings.+    private nonisolated enum CandidateSet: String, Sendable {+        case titleAndURL = "title+url"+        case titleOnly = "title-only"+        case urlOnly = "url-only"+    }++    /// Step 6 for one candidate set: the whole-hostname projection the editor's+    /// preview uses, read for the three things Req 3.4 names.+    private func verifies(+        hostname: String, candidateSet: CandidateSet, basis: ComposedTeachingBasis,+        request: ComposedTeachingRequest+    ) async throws -> Bool {+        // The timeout wrapper's check point between projections.+        try Task.checkCancellation()+        let outcome: ComposedTeachingOutcome+        do {+            outcome = try await library+                .projectComposedTeaching(hostname: hostname, request: request).outcome+        } catch {+            if error is CancellationError { throw error }+            RuleSuggestionLog.failure(+                "\(hostname): \(candidateSet.rawValue) projection failed — \(RuleSuggestionLog.describe(error))")+            return false+        }+        guard !outcome.requiresUnsettledAcknowledgment else {+            RuleSuggestionLog.note(+                "\(hostname): \(candidateSet.rawValue) rejected — chapters left unsettled")+            return false+        }+        for entry in outcome.entries {+            guard entry.titleFailure == nil, entry.urlFailure == nil,+                  let workName = entry.workName, !workName.isEmpty else {+                RuleSuggestionLog.note(+                    "\(hostname): \(candidateSet.rawValue) rejected — \(Self.failure(of: entry))",+                    content: "title=\"\(Self.captureTitle(of: entry, in: basis))\"")+                return false+            }+        }+        return true+    }++    /// Why one projected entry disqualified a candidate set. Req 3.4 names the+    /// three, and the first one that holds is the one reported.+    private nonisolated static func failure(of entry: ComposedEntryProjection) -> String {+        if let titleFailure = entry.titleFailure {+            return "titleFailure \(String(describing: titleFailure))"+        }+        if let urlFailure = entry.urlFailure {+            return "urlFailure \(String(describing: urlFailure))"+        }+        return "empty workName"+    }++    /// The projection carries no capture title, so the failing entry is named+    /// by looking its id back up in the basis it was projected from.+    private nonisolated static func captureTitle(+        of entry: ComposedEntryProjection, in basis: ComposedTeachingBasis+    ) -> String {+        basis.entries.first { $0.id == entry.entryID }?.captureTitle ?? "<entry not in basis>"+    }++    // MARK: - Helpers++    /// One URL field, located in exactly one component of the anchor URL.+    private nonisolated struct LocatedURLSpan: Sendable {+        let selection: ComposedTeachingPresentation.URLComponentSelection+        /// Component-relative, never a span over the raw URL.+        let range: Range<Int>+    }++    /// Every path component's text and every query value, searched for one+    /// field. Accepted only on exactly one occurrence across all of them: a+    /// slug that repeats has no single span, and text the model copied out of+    /// the host or the scheme is found in none (Req 3.1).+    private nonisolated static func locate(+        _ text: String, in components: RawURLLexicalComponents+    ) -> LocatedURLSpan? {+        guard !text.isEmpty else { return nil }+        var candidates: [(ComposedTeachingPresentation.URLComponentSelection, String)] = []+        for (index, component) in components.pathComponents.enumerated() {+            candidates.append((.path(index), component.value))+        }+        for (index, item) in components.queryItems.enumerated() {+            candidates.append((.query(index), item.value.value))+        }++        var found: LocatedURLSpan?+        for (selection, source) in candidates {+            switch ProposalLocator.place(text, in: source) {+            case .absent:+                continue+            case .unique(let range):+                // A second component that also holds it once is as ambiguous as+                // two occurrences in one, and is not resolved in either's favour.+                guard found == nil else { return nil }+                found = LocatedURLSpan(selection: selection, range: range)+            case .ambiguous:+                // Ambiguous within one component; the whole field is dropped+                // rather than resolved in favour of another component.+                return nil+            }+        }+        return found+    }++    private nonisolated static func request(+        title: TitleRuleSuggestion, url: URLRuleDefinition?+    ) -> ComposedTeachingRequest {+        ComposedTeachingRequest(+            titleDefinition: title.definition, trimPrefix: title.trimPrefix,+            trimSuffix: title.trimSuffix, urlDefinition: url,+            acknowledgeUnsettled: false, permitsArticlesConversion: false)+    }++    /// Req 3.4 / Q48: a single-side set fills the other slot with what the Site+    /// already holds, because that is the state the reader would save.+    private nonisolated static func storedTitle(_ basis: ComposedTeachingBasis) -> TitleRuleSuggestion {+        guard let stored = basis.currentTitleRule else {+            return TitleRuleSuggestion(definition: .wholeTitle)+        }+        return TitleRuleSuggestion(+            definition: stored.definition,+            trimPrefix: stored.trimPrefix, trimSuffix: stored.trimSuffix)+    }++    private nonisolated static func isUntaughtDefault(_ title: TitleRuleSuggestion) -> Bool {+        guard case .wholeTitle = title.definition else { return false }+        return (title.trimPrefix ?? "").isEmpty && (title.trimSuffix ?? "").isEmpty+    }+}
Asterism/Asterism/RuleSuggestion/RuleSuggestionAssembler.swift Added +94 / -0
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggestionAssembler.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggestionAssembler.swiftnew file mode 100644index 0000000..3adc0ed--- /dev/null+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggestionAssembler.swift@@ -0,0 +1,94 @@+import AsterismCore+import AsterismIntelligence+import Foundation++/// Step 5 of the attempt pipeline: turn located spans into the rules the editor+/// would have authored from the same selection.+///+/// Nothing here derives a rule of its own. The title path drives+/// `ComposedTeachingPresentation`'s chip inference and the URL path drives a+/// headless `URLEditorState`, because a suggestion that took a different route+/// could produce a rule the editor cannot depict — which is exactly what+/// Req 3.5 forbids (Q36). A refusal on either path is `nil`, not an error: the+/// spans came from a model, and "the editor would not author that" is an+/// ordinary answer.+///+/// `nonisolated`, and pure: this runs on the `RuleSuggester` actor, off the+/// main actor (Req 5.1).+nonisolated enum RuleSuggestionAssembler {++    /// The title rule a Work span and an optional chapter span author on the+    /// anchor capture's title, or nil when they author none.+    ///+    /// The default whole-title rule *is* returned here; whether it is worth+    /// showing on its own is the suggester's call (design step 5).+    static func titleRule(+        anchorTitle: String, workSpan: Range<Int>, chapterSpan: Range<Int>?+    ) -> TitleRuleSuggestion? {+        let count = anchorTitle.count+        guard !workSpan.isEmpty, workSpan.lowerBound >= 0, workSpan.upperBound <= count else {+            return nil+        }+        if let chapterSpan {+            guard !chapterSpan.isEmpty, chapterSpan.lowerBound >= 0,+                  chapterSpan.upperBound <= count, !chapterSpan.overlaps(workSpan)+            else { return nil }+        }++        let segments = ComposedTeachingPresentation.titleSegments(in: anchorTitle)+        let selection = ComposedTeachingPresentation.titleSelection(+            in: anchorTitle, workSpan: workSpan, chapterSpan: chapterSpan)+        let chips = ComposedTeachingPresentation.titleChips(+            segments: segments, subdividing: selection.subdivided)+        guard chips.count == selection.roles.count, selection.roles.contains(.work),+              let inferred = ComposedTeachingPresentation.inferredTitleRule(+                title: anchorTitle, segments: segments, chips: chips, roles: selection.roles)+        else { return nil }+        // A chapter span the chip row could not express drops silently out of+        // `titleSelection`; a rule that names no chapter when one was proposed+        // is still a legal rule, and Req 3.4's projection is what decides+        // whether it is worth showing.+        return TitleRuleSuggestion(+            definition: inferred.definition,+            trimPrefix: inferred.trimPrefix, trimSuffix: inferred.trimSuffix)+    }++    /// The URL rule an identity selection and an optional sequence selection+    /// author on the anchor capture's URL, or nil when they author none.+    ///+    /// The spans are component-relative and are used **only** for a+    /// within-component split: a slot that takes a whole component takes it+    /// whole, exactly as a chip tap does.+    static func urlRule(+        components: RawURLLexicalComponents,+        identity: (ComposedTeachingPresentation.URLComponentSelection, Range<Int>),+        sequence: (ComposedTeachingPresentation.URLComponentSelection, Range<Int>)?+    ) -> URLRuleDefinition? {+        var state = ComposedTeachingPresentation.URLEditorState()+        state.activeSlot = .work+        state.select(identity.0)++        if let sequence {+            if sequence.0 == identity.0 {+                // One component carrying both fields is the combined form, and+                // the split is the only way to say where the boundary falls.+                guard identity.1.upperBound <= sequence.1.lowerBound+                        || sequence.1.upperBound <= identity.1.lowerBound+                else { return nil }+                state.setSplit(+                    URLTwoFieldSelection(work: identity.1, sequence: sequence.1))+            } else {+                state.activeSlot = .sequence+                state.select(sequence.0)+                state.activeSlot = .work+            }+        }++        // Only a finished rule is a suggestion. `.pending` (a half-chosen+        // anchoring), `.unauthorable` (a component nothing can single out) and+        // `.cleared` (including a split the deriver refused) all mean the+        // editor authored nothing.+        guard case .valid(let definition) = state.rule(in: components).status else { return nil }+        return definition+    }+}
Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift Added +402 / -0
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swiftnew file mode 100644index 0000000..fad4a13--- /dev/null+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift@@ -0,0 +1,402 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import UIKit++/// The device conditions Req 5.3 gates the sweep on, plus the clock the+/// coordinator measures a cancelled attempt with.+///+/// A protocol rather than direct `ProcessInfo` reads: Low Power Mode, the+/// thermal state and the app's active state cannot be set from a test, and the+/// gates are most of what the coordinator does.+@MainActor+protocol SuggestionEnvironment {+    var isActive: Bool { get }+    var isLowPowerModeEnabled: Bool { get }+    var thermalState: ProcessInfo.ThermalState { get }+    var now: ContinuousClock.Instant { get }+}++/// The production environment.+@MainActor+struct SystemSuggestionEnvironment: SuggestionEnvironment {+    /// Explicit and `nonisolated` so the coordinator can name it as a default+    /// argument: a default-argument generator is nonisolated, and the implicit+    /// memberwise initializer of a main-actor type is not.+    nonisolated init() {}++    var isActive: Bool { UIApplication.shared.applicationState == .active }+    var isLowPowerModeEnabled: Bool { ProcessInfo.processInfo.isLowPowerModeEnabled }+    var thermalState: ProcessInfo.ThermalState { ProcessInfo.processInfo.thermalState }+    var now: ContinuousClock.Instant { .now }+}++/// Owns everything a suggestion needs that is not the attempt itself: the+/// ledger, the in-flight `Task`, the callers waiting on it, and the library+/// read that produces a hostname's fingerprint.+///+/// `@MainActor` because the view model reads `held`/`isDismissed`+/// synchronously; the attempt runs in a `RuleSuggester` actor, so no model work+/// touches this actor (Q40, Req 5.1).+@MainActor @Observable+final class RuleSuggestionCoordinator {+    /// Req 4.1/6.1: nothing is attempted and the Suggest action is hidden while+    /// the model is away. Re-read on every activation, because `.modelNotReady`+    /// is transient (Q41).+    private(set) var isModelAvailable: Bool++    private let library: any LibraryProviding+    private let model: any RuleSuggestionModelClient+    private let suggester: any RuleSuggesting+    private let environment: any SuggestionEnvironment++    private var ledger = RuleSuggestionLedger()+    /// The attempt is the coordinator's, never the caller's: cancelling whoever+    /// asked must not cancel the work (design, coordinator section).+    private var attemptTask: Task<Void, Never>?+    /// When the current attempt started, so a cancellation — which returns no+    /// `modelPhase` of its own — can still be charged to the budget (Req 5.2).+    private var attemptStartedAt: ContinuousClock.Instant?+    /// Everyone awaiting the in-flight attempt: the caller that started it and+    /// anyone who attached (Q16).+    private var waiters: [CheckedContinuation<RuleSuggestion?, Never>] = []+    /// The sweep currently running, so the next activation can queue behind it+    /// rather than race it. See `activationSweep()`.+    private var sweepTask: Task<Void, Never>?++    init(+        library: any LibraryProviding,+        model: any RuleSuggestionModelClient,+        suggester: any RuleSuggesting,+        environment: any SuggestionEnvironment = SystemSuggestionEnvironment()+    ) {+        self.library = library+        self.model = model+        self.suggester = suggester+        self.environment = environment+        self.isModelAvailable = model.availability().isAvailable+    }++    // MARK: - Reads++    func held(for hostname: String) -> RuleSuggestion? { ledger.held(for: hostname) }++    func isDismissed(hostname: String, side: Side) -> Bool {+        ledger.isDismissed(hostname: hostname, side: side)+    }++    /// Whether an attempt for this hostname has settled this run. Read by the+    /// editor to decide whether an on-open computation is even possible+    /// (Req 5.7), and by the tests.+    func isAttempted(_ hostname: String) -> Bool { ledger.isAttempted(hostname) }++    /// What the run has spent so far (Req 5.2).+    var budgetSpent: Duration { ledger.budgetSpent }++    // MARK: - Dismissal (Reqs 2.7, 2.8, 6.5)++    func dismiss(hostname: String, side: Side) {+        ledger.dismiss(hostname: hostname, side: side)+    }++    func clearDismissal(hostname: String, side: Side) {+        ledger.clearDismissal(hostname: hostname, side: side)+    }++    // MARK: - Activation (Req 5.1)++    /// One sweep per activation: re-read availability, then attempt up to+    /// `backgroundSweepDepth` auto-eligible hostnames, most recent capture+    /// first. Never awaited inline by the caller — the app's activation must+    /// not wait on it.+    ///+    /// Activations queue rather than collide. `resignActive()` stops a sweep+    /// but cannot end it — the coroutine runs until the attempt it is awaiting+    /// returns — so an app that comes straight back finds the previous sweep+    /// still in the loop. Waiting for it and then beginning a fresh one is what+    /// gives every activation the sweep Req 5.1 promises it.+    func activationSweep() async {+        let previous = sweepTask+        let task = Task { [weak self] in+            await previous?.value+            await self?.runSweep()+        }+        sweepTask = task+        await task.value+        if sweepTask == task { sweepTask = nil }+    }++    /// One sweep, from the ledger's point of view. Its generation is the token+    /// the loop asks about and the only one that may end it: a sweep stopped+    /// mid-flight must not switch off the sweep that replaced it.+    private func runSweep() async {+        guard let generation = ledger.beginSweep() else { return }+        defer { ledger.endSweep(generation: generation) }++        let availability = model.availability()+        isModelAvailable = availability.isAvailable+        if case .unavailable(let reason) = availability {+            RuleSuggestionLog.note("sweep skipped — model unavailable: \(reason)")+            return+        }++        // Asked before the read, not after: with the budget spent every+        // candidate would be refused anyway (Q31), and the read is 2N SwiftData+        // fetches to be told so.+        guard !ledger.budgetExhausted else {+            RuleSuggestionLog.note(+                "sweep skipped — run budget spent (\(RuleSuggestionLog.milliseconds(ledger.budgetSpent)) ms)")+            return+        }++        let candidates: [RuleSuggestionCandidate]+        do {+            candidates = try await library.ruleSuggestionCandidates(hostnames: nil)+        } catch {+            // A library that is not ready yet skips this activation in silence+            // (Req 4.2); the next one tries again.+            RuleSuggestionLog.failure(+                "sweep skipped — candidate read failed: \(RuleSuggestionLog.describe(error))")+            return+        }++        let eligible = candidates+            .filter {+                Self.isAutoEligible($0) && !ledger.isAttempted($0.hostname)+                    // Req 2.8: the reader rejected both sides, and `invalidate`+                    // keeps that rejection — so a corpus change must not spend+                    // a sweep slot and up to 10 s of budget re-offering it.+                    && !ledger.isFullyDismissed($0.hostname)+            }+            .sorted(by: Self.byRecency)+            .prefix(RuleSuggestionBounds.backgroundSweepDepth)+        RuleSuggestionLog.note(+            "sweep: \(eligible.count) of \(candidates.count) hostnames eligible")++        for candidate in eligible {+            // A pre-emption or a resign-active ends *this* sweep; the next+            // activation restarts it.+            guard ledger.isSweeping(generation: generation) else { return }+            _ = await suggestion(for: candidate.hostname, origin: .background, candidate: candidate)+        }+    }++    /// Req 5.5's single invalidation hook, called after every library refresh.+    /// The comparison itself is the ledger's (Q57); this only performs the read.+    func reconcile() async {+        let tracked = ledger.trackedHostnames+        guard !tracked.isEmpty else { return }+        let rows: [RuleSuggestionCandidate]+        do {+            rows = try await library.ruleSuggestionCandidates(hostnames: tracked)+        } catch {+            return+        }+        var fingerprints: [String: CorpusFingerprint] = [:]+        for row in rows { fingerprints[row.hostname] = Self.fingerprint(row) }++        let invalidated = ledger.reconcile(against: fingerprints)+        if let inFlight = ledger.inFlight, invalidated.contains(inFlight.hostname) {+            // The record is already voided, so whatever the attempt returns is+            // discarded; cancelling only stops it sooner.+            attemptTask?.cancel()+        }+    }++    /// Req 5.3: the sweep stops with the foreground, and so does its attempt.+    /// On-open and on-request work is the reader's and continues.+    func resignActive() {+        if ledger.resignActive() != nil { attemptTask?.cancel() }+    }++    /// Req 5.6.+    func memoryWarning() {+        if ledger.memoryWarning() != nil { attemptTask?.cancel() }+    }++    // MARK: - Delivery++    /// The single delivery channel for on-open and on-request (and the sweep's+    /// own starts). Nil means the attempt settled with nothing, or the ledger+    /// refused to start one.+    func suggestion(for hostname: String, origin: Origin) async -> RuleSuggestion? {+        await suggestion(for: hostname, origin: origin, candidate: nil)+    }++    private func suggestion(+        for hostname: String, origin: Origin, candidate: RuleSuggestionCandidate?+    ) async -> RuleSuggestion? {+        // Req 6.6: a held result is the answer, with no model call.+        if let held = ledger.held(for: hostname) { return held }+        guard isModelAvailable else {+            RuleSuggestionLog.note("\(hostname): \(origin.rawValue) refused — model unavailable")+            return nil+        }+        // Ask the ledger before paying for a candidate row: an editor open on a+        // hostname already attempted, dismissed on both sides, or standing+        // behind the reader's own request is refused on state already in hand,+        // and the read it would otherwise make is 2N fetches per open.+        if origin == .open, let reason = ledger.openRefusal(hostname: hostname) {+            RuleSuggestionLog.note("\(hostname): open refused — \(reason)")+            return nil+        }++        // The fingerprint an attempt is recorded against has to come from the+        // library, so a start needs a candidate row. The sweep has one already.+        var row = candidate+        if row == nil {+            row = try? await library.ruleSuggestionCandidates(hostnames: [hostname]).first+        }+        guard let row else {+            RuleSuggestionLog.note(+                "\(hostname): \(origin.rawValue) refused — the library returned no candidate row")+            return nil+        }+        let fingerprint = Self.fingerprint(row)++        while true {+            // Re-read each time round: `cancelInFlight()` below awaits another+            // attempt's termination, and a settlement during that suspension+            // may have held a suggestion for this hostname (Req 6.6).+            if let held = ledger.held(for: hostname) { return held }+            switch ledger.start(+                hostname: hostname, origin: origin, fingerprint: fingerprint,+                environment: currentEnvironment+            ) {+            case .refuse(let reason):+                RuleSuggestionLog.note("\(hostname): \(origin.rawValue) refused — \(reason)")+                return nil+            case .attach:+                RuleSuggestionLog.note(+                    "\(hostname): \(origin.rawValue) attached to the attempt already in flight")+                return await awaitAttempt()+            case .preempt(let preempted):+                // Q54's two-step protocol: the ledger will not swap the record+                // itself, so the running attempt is cancelled and awaited — it+                // settles itself on the way out — and only then asked again.+                RuleSuggestionLog.note(+                    "\(hostname): \(origin.rawValue) pre-empts the attempt for \(preempted)")+                await cancelInFlight()+            case .start:+                RuleSuggestionLog.note("\(hostname): attempt start (\(origin.rawValue))")+                beginAttempt(hostname: hostname)+                return await awaitAttempt()+            }+        }+    }++    // MARK: - The attempt Task++    private func beginAttempt(hostname: String) {+        attemptStartedAt = environment.now+        attemptTask = Task { [weak self] in+            guard let self else { return }+            do {+                let result = try await self.suggester.attempt(hostname: hostname)+                self.settle(+                    hostname: hostname,+                    settlement: result.suggestion.map { .suggestion($0) } ?? .noSuggestion,+                    modelPhase: result.modelPhase)+            } catch let timeout as AttemptTimeout {+                // Attempted, and charged what it spent (Q45, Req 5.4).+                self.settle(hostname: hostname, settlement: .timedOut,+                            modelPhase: timeout.modelPhase)+            } catch {+                // The app's own cancellation: unattempted, still charged (Q28).+                self.settle(hostname: hostname, settlement: .cancelled,+                            modelPhase: self.elapsedSinceStart())+            }+        }+    }++    /// Only ever reached from the attempt's own `Task`, which is the only thing+    /// that can end an attempt — so clearing the handles here cannot strand a+    /// newer one: a new attempt starts only after this one has terminated.+    private func settle(+        hostname: String, settlement: AttemptSettlement, modelPhase: Duration+    ) {+        RuleSuggestionLog.note(+            "\(hostname): settled \(Self.describe(settlement)) after \(RuleSuggestionLog.milliseconds(modelPhase)) ms")+        ledger.settle(hostname: hostname, settlement, modelPhase: modelPhase)+        attemptTask = nil+        attemptStartedAt = nil++        let result = ledger.held(for: hostname)+        let resuming = waiters+        waiters = []+        for continuation in resuming { continuation.resume(returning: result) }+    }++    private func awaitAttempt() async -> RuleSuggestion? {+        // Deliberately not cancellation-aware: the caller may go away, the+        // attempt may not.+        await withCheckedContinuation { continuation in+            waiters.append(continuation)+        }+    }++    /// Req 5.11: the next attempt may not start until this one has stopped.+    private func cancelInFlight() async {+        guard let task = attemptTask else { return }+        task.cancel()+        await task.value+    }++    private func elapsedSinceStart() -> Duration {+        guard let attemptStartedAt else { return .zero }+        return attemptStartedAt.duration(to: environment.now)+    }++    private var currentEnvironment: RuleSuggestionEnvironment {+        RuleSuggestionEnvironment(+            isActive: environment.isActive,+            isLowPowerMode: environment.isLowPowerModeEnabled,+            thermalState: environment.thermalState)+    }++    // MARK: - Candidate rows++    /// The definition in the requirements: not articles, at least one capture,+    /// and a rule missing on one side.+    private static func isAutoEligible(_ candidate: RuleSuggestionCandidate) -> Bool {+        candidate.siteMode != .articles && candidate.entryCount > 0+            && (candidate.titleRuleVersion == nil || candidate.urlRuleVersion == nil)+    }++    /// Most recent capture first; a hostname with no captures is never eligible,+    /// so the nil arm only has to be total. Hostname breaks ties so a sweep is+    /// reproducible.+    private static func byRecency(+        _ left: RuleSuggestionCandidate, _ right: RuleSuggestionCandidate+    ) -> Bool {+        switch (left.latestCaptureAt, right.latestCaptureAt) {+        case (let l?, let r?) where l != r: return l > r+        case (nil, _?): return false+        case (_?, nil): return true+        default: return left.hostname < right.hostname+        }+    }++    /// How the attempt ended, for the log: which sides a suggestion carried,+    /// or which of the three endings that hold nothing this was.+    private static func describe(_ settlement: AttemptSettlement) -> String {+        switch settlement {+        case .suggestion(let suggestion):+            let sides = [+                suggestion.title == nil ? nil : "title", suggestion.url == nil ? nil : "url",+            ].compactMap { $0 }+            return sides.isEmpty ? "a suggestion with no sides" : "suggestion (\(sides.joined(separator: "+")))"+        case .noSuggestion: return "no suggestion"+        case .timedOut: return "timed out"+        case .cancelled: return "cancelled"+        }+    }++    private static func fingerprint(_ candidate: RuleSuggestionCandidate) -> CorpusFingerprint {+        CorpusFingerprint(+            siteMode: candidate.siteMode, entryCount: candidate.entryCount,+            latestCaptureAt: candidate.latestCaptureAt,+            titleRuleVersion: candidate.titleRuleVersion,+            urlRuleVersion: candidate.urlRuleVersion)+    }+}
Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift Added +79 / -0
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swiftnew file mode 100644index 0000000..b9577e8--- /dev/null+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift@@ -0,0 +1,79 @@+import AsterismIntelligence+import Foundation+import OSLog++/// The rule-suggestion pipeline's diagnostic log.+///+/// Every failure in this pipeline settles the attempt with `nil` and tells the+/// reader nothing (Req 4.2), so a hostname that produces no suggestion is+/// otherwise indistinguishable from one the model was never asked about. These+/// lines are the only place the reason survives.+///+/// Follow one run in Console.app with+/// `subsystem:me.nore.ig.Asterism category:RuleSuggestion`, or after the fact+/// with+/// `log show --predicate 'subsystem == "me.nore.ig.Asterism" AND category == "RuleSuggestion"' --last 10m`.+///+/// `nonisolated` because the suggester runs on its own actor, off the main one+/// (Req 5.1), and every static under this target's default isolation would+/// otherwise be main-actor bound.+nonisolated enum RuleSuggestionLog {+    static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "RuleSuggestion")++    /// `reason` — hostname, step, origin, error type, count, duration — is+    /// always readable. `content` — capture titles, URLs, the text the model+    /// copied out of them — is readable only in a debug build; a release build+    /// leaves it to the logging system to redact.+    ///+    /// The `#if` has to wrap the whole call rather than a constant: `privacy:`+    /// accepts nothing but a literal member of `OSLogPrivacy` — not a variable,+    /// and not a static of our own ("argument must be a static method or+    /// property of 'OSLogPrivacy'").+    ///+    /// Both arguments are autoclosures and neither is evaluated until the level+    /// is known to be enabled. Every call site here interpolates — a proposal's+    /// four fields, a capture title looked back up in the basis, an error+    /// stringified — and `Logger`'s own laziness cannot help with that, because+    /// the interpolation happens at the call site, before the message is ever+    /// handed over.+    static func note(+        _ reason: @autoclosure () -> String,+        content: @autoclosure () -> String? = nil,+        level: OSLogType = .default+    ) {+        guard logger.isEnabled(type: level) else { return }+        // Both are bound to locals first: `OSLogMessage`'s interpolation is an+        // *escaping* autoclosure, and a non-escaping parameter cannot be+        // captured by one.+        let reason = reason()+        guard let content = content() else {+            logger.log(level: level, "\(reason, privacy: .public)")+            return+        }+        #if DEBUG+        logger.log(level: level, "\(reason, privacy: .public) | \(content, privacy: .public)")+        #else+        logger.log(level: level, "\(reason, privacy: .public) | \(content, privacy: .private)")+        #endif+    }++    /// A step that failed with an error rather than an answer.+    static func failure(+        _ reason: @autoclosure () -> String, content: @autoclosure () -> String? = nil+    ) {+        note(reason(), content: content(), level: .error)+    }++    /// The one spelling of a failure this pipeline's logs use, shared with the+    /// package so a model client's default `describe(_:)` reads the same.+    static func describe(_ error: any Error) -> String {+        SuggestionFailure.describe(error)+    }++    /// Durations read better in the log as a plain millisecond count than as+    /// `Duration`'s "0.123 seconds".+    static func milliseconds(_ duration: Duration) -> Int64 {+        let components = duration.components+        return components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000+    }+}
Asterism/Asterism/UITestLaunchSupport.swift Modified +36 / -0
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex 47c278b..f859624 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -1,4 +1,5 @@ import AsterismCore+import AsterismIntelligence import Foundation  /// Injectable process-environment boundary used by the debug UI-test launcher.@@ -93,6 +94,41 @@ enum UITestLaunchSupport {     /// second table.     static let seededToleratedPrefix = "seeded-tolerated-" +    /// `rule-suggestion`: which scripted model client this launch runs with.+    /// Absent means the on-device model, which is what production always uses.+    static let suggestionKey = "ASTERISM_UI_TEST_SUGGESTION"+    /// A proposal that verifies on the `seeded-composed` fixture's untaught+    /// capture — `TtH - Story - Real Title` at+    /// `https://composed.test/Story-28614-94/slug.htm`. The last title segment+    /// names the Work and the first is the chapter; the URL's first path+    /// component identifies the Work.+    static let cannedSuggestionProposal = RuleProposal(+        workName: "Real Title", chapterText: "TtH",+        urlWorkIdentity: "Story-28614-94", urlSequenceText: "")++    /// The model client this launch's suggestions run against, or nil when the+    /// launch asked for nothing (every production launch, and every UI test but+    /// the two `rule-suggestion` scenarios).+    ///+    /// Gated because `StubRuleSuggestionModelClient` is: the scripted client is+    /// a test double and carries the package's fixture gate, so a shipping+    /// build has neither it nor this.+    #if DEBUG || ASTERISM_PERFORMANCE_TESTING+    static func suggestionClient(+        environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment()+    ) -> (any RuleSuggestionModelClient)? {+        switch environmentProvider.environment[suggestionKey] {+        case "canned":+            return StubRuleSuggestionModelClient(proposal: cannedSuggestionProposal)+        case "unavailable":+            return StubRuleSuggestionModelClient(+                availability: .unavailable(reason: "UI test"))+        default:+            return nil+        }+    }+    #endif+     static func request(         environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment(),         temporaryDirectory: URL = FileManager.default.temporaryDirectory
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +36 / -2
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex ad1bb83..3da6b15 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -1,4 +1,5 @@ import AsterismCore+import AsterismIntelligence import Foundation import OSLog @@ -131,6 +132,11 @@ public final class AppLibraryModel {     /// Cleared after seeding so a later in-process bootstrap cannot seed twice.     private var uiTestFixture: UITestFixtureKind? +    /// The run's rule suggestions (`rule-suggestion`). Built at bootstrap over+    /// the repository that opened, so it is nil until the library is ready and+    /// in the test seams that publish a repository without one.+    private(set) var suggestions: RuleSuggestionCoordinator?+     /// Production initializer: resolves the configuration from the App Group     /// identifier its bundle declares. Fails closed (→ unavailable) when App     /// Group resolution fails.@@ -291,6 +297,10 @@ public final class AppLibraryModel {             }             self.repository = repo             self.backupRepository = repo+            // `rule-suggestion`: the run's suggestion state, built over the+            // repository that just opened. Nothing is attempted until an+            // activation sweeps or an editor opens.+            self.suggestions = Self.makeSuggestionCoordinator(library: repo)             interruptedImport = await repo.interruptedImport()             // Before `refreshAll()`, so a capture this pass commits is in the             // first snapshots rather than waiting for the next activation, and@@ -380,6 +390,25 @@ public final class AppLibraryModel {         // the larger budget — nothing is waiting on it but the refresh (Q40).         await drainPendingCaptures(budget: PendingCaptureBounds.drainPassTimeBudget)         await refreshDiagnosesAndSnapshots()+        // `rule-suggestion` Req 5.1: never awaited inline — the activation must+        // not wait on model work, and the sweep may outlive this call.+        if let suggestions {+            Task { await suggestions.activationSweep() }+        }+    }++    /// The model client the run's suggestions are computed with. A UI test may+    /// substitute a scripted one; production always asks the on-device model.+    private static func makeSuggestionCoordinator(+        library: any LibraryProviding+    ) -> RuleSuggestionCoordinator {+        var client: any RuleSuggestionModelClient = FoundationRuleSuggestionModelClient()+        #if DEBUG || ASTERISM_PERFORMANCE_TESTING+        if let scripted = UITestLaunchSupport.suggestionClient() { client = scripted }+        #endif+        return RuleSuggestionCoordinator(+            library: library, model: client,+            suggester: RuleSuggester(library: library, model: client))     }      // MARK: - Preserved captures (pending-capture-queue)@@ -571,6 +600,9 @@ public final class AppLibraryModel {     private func refreshDiagnosesAndSnapshots() async {         await refreshDiagnoses()         await refreshAll()+        // `rule-suggestion` Req 5.5's single invalidation hook: every library+        // mutation the app performs funnels through here (Q43).+        await suggestions?.reconcile()         dropSettledConflicts()     } @@ -769,7 +801,8 @@ public final class AppLibraryModel {             entryContext: context,             onMutation: { [weak self] in                 await self?.refreshDiagnosesAndSnapshots()-            }+            },+            suggestions: suggestions         )     } @@ -806,7 +839,8 @@ public final class AppLibraryModel {             permitsArticlesConversion: permitsArticlesConversion,             onMutation: { [weak self] in                 await self?.refreshDiagnosesAndSnapshots()-            }+            },+            suggestions: suggestions         )     } 
Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift Modified +450 / -27
diff --git a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swiftindex fc80a1e..71fd06b 100644--- a/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift+++ b/Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift@@ -1,4 +1,5 @@ import AsterismCore+import AsterismIntelligence import Foundation import OSLog @@ -65,6 +66,30 @@ public final class ComposedTeachingViewModel {         case urlFocused     } +    /// Why a suggestion is being applied. The automatic path (editor open, late+    /// arrival) only touches sides the reader has neither taught nor changed nor+    /// dismissed; the reader's own request applies to any side (Reqs 1.1, 6.2).+    public enum SuggestionApplication: Equatable, Sendable {+        case automatic+        case request+    }++    /// One side's editor state as it stood before a suggestion was applied, so+    /// the clear action can put it back exactly (Req 2.3, Q42).+    private struct TitleSuggestionSnapshot {+        var subdividedSegments: Set<Int>+        var chips: [ComposedTeachingPresentation.TitleChip]+        var roles: [SegmentRole]+        var edited: Bool+        var storedNotice: String?+    }++    private struct URLSuggestionSnapshot {+        var definition: URLRuleDefinition?+        var status: ComposedTeachingPresentation.URLRuleStatus+        var disclosure: DisclosureState+    }+     // MARK: - Published state      public private(set) var state: State = .loading@@ -125,6 +150,36 @@ public final class ComposedTeachingViewModel {     // Acknowledgment (Req 2.1, Q3)     public private(set) var acknowledgedUnsettled: Bool = false +    // Rule suggestion (rule-suggestion Reqs 1, 2, 6)+    /// Whether a suggested rule is applied and unedited on that side — the+    /// marker's only condition (Req 2.1).+    ///+    /// Derived rather than tracked: the applied payload below is the marker's+    /// only evidence, and the two were only ever written together.+    public var titleSuggestionApplied: Bool { appliedTitleSuggestion != nil }+    public var urlSuggestionApplied: Bool { appliedURLSuggestion != nil }+    /// True while any computation for this hostname is being awaited, on open or+    /// on request (Req 6.3).+    public private(set) var suggestionBusy = false+    /// A suggestion exists for this hostname that the automatic path did not+    /// apply — late, touched, or dismissed (Reqs 5.9, Q68).+    ///+    /// Read from the state rather than latched. The coordinator holds the+    /// suggestion before it resumes anyone waiting on the attempt, so every+    /// side of it is knowable here: what is offered, what took, and what the+    /// reader has since rejected. A flag set on delivery and cleared on request+    /// could say none of that after a `clearSuggestedSide`.+    public var suggestionReady: Bool {+        guard let suggestions, let held = suggestions.held(for: hostname) else { return false }+        return Side.allCases.contains { side in+            Self.offers(held, side) && !isSuggestionApplied(side)+                && !suggestions.isDismissed(hostname: hostname, side: side)+        }+    }+    /// Req 6.4's message, and the only thing about suggestions the reader is+    /// ever told.+    public private(set) var suggestionUnavailableNotice: String?+     // MARK: - Dependencies      private let entry: EntrySnapshot@@ -138,6 +193,9 @@ public final class ComposedTeachingViewModel {     /// from converting one by accident.     private let permitsArticlesConversion: Bool     private let onMutation: (@Sendable () async -> Void)?+    /// The run's suggestion state. Nil wherever suggestions are not wired —+    /// previews, and every test that does not exercise them.+    private let suggestions: RuleSuggestionCoordinator?      private var contract: ComposedTeachingContract?     private var previewTask: Task<Void, Never>?@@ -154,6 +212,30 @@ public final class ComposedTeachingViewModel {     /// so a reader who collapses it again is not fought by the same trigger.     private var didAutoExpandForChapter = false +    /// When the editor became interactive, which is what Req 5.8's auto-apply+    /// window is measured from.+    private var appearedAt: ContinuousClock.Instant?+    /// The URL side's "changed": any outcome the URL editor dispatched after+    /// load (the title side uses `titleEdited`). Reqs 5.8, 5.9.+    private var urlTouched = false+    /// Req 5.8's window.+    ///+    /// **Test seam.** Production never assigns this: `RuleSuggestionBounds` is+    /// the one home for the bound (Q11/Q22). It is settable so the late-arrival+    /// path can be driven with a zero window instead of waiting out the real two+    /// seconds.+    var suggestionAutoApplyWindow: Duration = RuleSuggestionBounds.autoApplyWindow+    private var titleSuggestionSnapshot: TitleSuggestionSnapshot?+    private var urlSuggestionSnapshot: URLSuggestionSnapshot?+    /// What is currently applied per side, so an incoming edit can be compared+    /// against it and a commit can tell "saved the suggestion" from "saved+    /// something else" (Reqs 2.6, 2.7).+    private var appliedTitleSuggestion: TitleRuleSuggestion?+    private var appliedURLSuggestion: URLRuleDefinition?+    /// The on-open delivery. Cancelled with the editor; the attempt behind it is+    /// the coordinator's and continues (Req 5.7).+    private var suggestionTask: Task<Void, Never>?+     /// Articles mode is a one-way Site transition, not a composed title rule, so     /// it keeps its own contract and commits through `commitArticles` rather than     /// `commitComposedTeaching` (which requires an active title rule).@@ -174,9 +256,11 @@ public final class ComposedTeachingViewModel {     public var isSubdivided: Bool { !subdividedSegments.isEmpty }      /// The rule the surface would commit right now: the Site's retained rule-    /// until the reader edits the title, then the rule inferred from the chips.+    /// until the reader edits the title — or a suggestion is applied over it,+    /// which Req 6.2 says the commit must store rather than the retained rule+    /// (Q33) — then the rule inferred from the chips.     public var effectiveTitleRule: ComposedTeachingPresentation.InferredTitleRule? {-        if !titleEdited, let retained = retainedTitleRule {+        if !titleEdited, !titleSuggestionApplied, let retained = retainedTitleRule {             return ComposedTeachingPresentation.InferredTitleRule(                 definition: retained.definition,                 trimPrefix: retained.trimPrefix, trimSuffix: retained.trimSuffix)@@ -570,13 +654,18 @@ public final class ComposedTeachingViewModel {      // MARK: - Init -    public init(+    /// Internal, unlike the other view models' initializers: `suggestions` names+    /// an app-internal type, and nothing outside this module constructs the+    /// model — the unit tests reach it through `@testable`. Making it `public`+    /// would only force `RuleSuggestionCoordinator` public with it.+    init(         entry: EntrySnapshot,         library: any LibraryProviding,         capabilities: AsterismCapabilities = .m4,         entryContext: EntryContext = .titleFocused,         permitsArticlesConversion: Bool = false,-        onMutation: (@Sendable () async -> Void)? = nil+        onMutation: (@Sendable () async -> Void)? = nil,+        suggestions: RuleSuggestionCoordinator? = nil     ) {         self.entry = entry         self.library = library@@ -584,6 +673,7 @@ public final class ComposedTeachingViewModel {         self.entryContext = entryContext         self.permitsArticlesConversion = permitsArticlesConversion         self.onMutation = onMutation+        self.suggestions = suggestions     }      // MARK: - Lifecycle@@ -619,8 +709,12 @@ public final class ComposedTeachingViewModel {             contract = nil             previewOutcome = nil             state = .ready+            // The editor is interactive from here, which is what Req 5.8's+            // auto-apply window is measured from.+            appearedAt = .now             syncChapterRemedyDisclosure()             await generatePreviewIfValid()+            await seedSuggestionIfAvailable()         } catch {             errorMessage = "Unable to load teaching basis. \(error.localizedDescription)"             state = .error@@ -809,6 +903,14 @@ public final class ComposedTeachingViewModel {     /// 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) {+        // Q47: this is the URL side's edit detection. `beginGesture` cannot be:+        // it is private and runs on the re-seed the suggestion itself triggers,+        // which would dismiss the suggestion the instant it applied. An outcome+        // equal to what is applied is the editor echoing its own seed.+        if urlSuggestionApplied, !isAppliedURLSuggestion(outcome.definition) {+            retireSuggestion(.url, restoringSnapshot: false)+        }+        urlTouched = true         urlRuleStatus = outcome.status         urlRuleDefinition = outcome.definition         invalidatePreview()@@ -833,6 +935,291 @@ public final class ComposedTeachingViewModel {                 status: definition.map { .valid($0) } ?? .cleared))     } +    // MARK: - Rule suggestion (rule-suggestion Reqs 1, 2, 5.7–5.9, 6)++    /// Req 6.4's wording, in the same register as the editor's other notices.+    public static let noSuggestionNotice = "No suggestion available for this site."++    /// Req 6.1: the action exists only while the model is there and the Site is+    /// something a rule can be taught for.+    public var offersSuggestAction: Bool {+        suggestions?.isModelAvailable == true && frozenBasis?.siteMode != .articles+    }++    /// Req 6: ask for a suggestion now. A held one is applied without a model+    /// call; anything else waits on the coordinator's attempt.+    public func requestSuggestion() async {+        guard let suggestions, offersSuggestAction, !suggestionBusy else { return }+        suggestionUnavailableNotice = nil+        suggestionBusy = true+        let result = await suggestions.suggestion(for: hostname, origin: .request)+        // The editor may have been dismissed while the attempt ran: `cancel()`+        // is the only thing that sets `.cancelled`, and applying here would+        // overwrite it with a fresh preview for a surface that is gone.+        guard state != .cancelled else { return }+        suggestionBusy = false+        guard let result, !result.isEmpty else {+            suggestionUnavailableNotice = Self.noSuggestionNotice+            return+        }+        // A suggestion this capture cannot depict leaves both sides unchanged+        // (Req 1.4 on request), which the reader must not read as "applied".+        if await applySuggestion(result, origin: .request).isEmpty {+            suggestionUnavailableNotice = Self.noSuggestionNotice+        }+    }++    /// Req 2.3: return one side to the state it had before the suggestion —+    /// its untaught initial state, or the retained rule on a taught side (Q42).+    public func clearSuggestedSide(_ side: Side) {+        guard isSuggestionApplied(side) else { return }+        retireSuggestion(side, restoringSnapshot: true)+        invalidatePreview()+        syncChapterRemedyDisclosure()+        Task { await generatePreviewIfValid() }+    }++    /// Applies whichever sides the origin allows, and returns the ones that took.+    @discardableResult+    public func applySuggestion(+        _ suggestion: RuleSuggestion, origin: SuggestionApplication+    ) async -> Set<Side> {+        var applied: Set<Side> = []+        if let title = suggestion.title, accepts(.title, origin: origin),+           applyTitleSuggestion(title) {+            applied.insert(.title)+        }+        if let url = suggestion.url, accepts(.url, origin: origin), applyURLSuggestion(url) {+            applied.insert(.url)+        }+        guard !applied.isEmpty else { return applied }+        if origin == .request {+            // Req 6.5: applying to a dismissed side clears its dismissal.+            for side in applied { suggestions?.clearDismissal(hostname: hostname, side: side) }+            suggestionUnavailableNotice = nil+        }+        // Req 1.5: the preview shows the projected result for the whole+        // hostname exactly as it does for a hand-authored selection.+        invalidatePreview()+        syncChapterRemedyDisclosure()+        await generatePreviewIfValid()+        return applied+    }++    /// Req 1: apply what is held, or — on an auto-eligible hostname nothing has+    /// been attempted for — start the computation this open needs (Req 5.7).+    private func seedSuggestionIfAvailable() async {+        guard let suggestions, suggestions.isModelAvailable else { return }+        if let held = suggestions.held(for: hostname) {+            await applySuggestion(held, origin: .automatic)+            // Req 5.7 keys on "no held suggestion for **any of its untaught+            // sides**": a hold that only covers a side this hostname has already+            // been taught leaves the untaught side with nothing, so the open+            // still asks for a computation. The coordinator refuses it while the+            // hold's own attempt still counts as this run's attempt, which is+            // every case today; the check is here so the requirement holds+            // wherever a hold outlives its attempt.+            if coversAnUntaughtSide(held) { return }+        }+        guard isAutoEligibleForSuggestion, !suggestions.isAttempted(hostname) else { return }+        suggestionBusy = true+        let host = hostname+        suggestionTask = Task { [weak self] in+            let result = await suggestions.suggestion(for: host, origin: .open)+            guard let self, !Task.isCancelled else { return }+            // Req 6.3: the action stays busy until the delivery has finished+            // applying, so the reader cannot tap Suggest into a half-applied+            // side. `cancel()` clears the flag on the path that returns early.+            defer { self.suggestionBusy = false }+            guard let result, !result.isEmpty else { return }+            await self.deliverOnOpenSuggestion(result)+        }+    }++    /// Whether a held suggestion offers anything for a side this hostname has no+    /// stored rule for — Req 5.7's "held suggestion for any of its untaught+    /// sides".+    private func coversAnUntaughtSide(_ suggestion: RuleSuggestion) -> Bool {+        guard let basis = frozenBasis else { return true }+        if suggestion.title != nil, basis.currentTitleRule == nil { return true }+        if suggestion.url != nil, basis.currentURLRule == nil { return true }+        return false+    }++    /// Reqs 5.8/5.9: within the window the untouched sides are applied; past it+    /// the result is held and only the on-request action says so — which+    /// `suggestionReady` reads off the hold, so there is nothing to record here.+    private func deliverOnOpenSuggestion(_ suggestion: RuleSuggestion) async {+        let inWindow = appearedAt+            .map { $0.duration(to: .now) <= suggestionAutoApplyWindow } ?? false+        guard inWindow else { return }+        await applySuggestion(suggestion, origin: .automatic)+    }++    /// The requirements' auto-eligibility, asked of the basis this editor froze:+    /// not articles, and a rule missing on one side.+    private var isAutoEligibleForSuggestion: Bool {+        guard let basis = frozenBasis else { return false }+        return basis.siteMode != .articles+            && (basis.currentTitleRule == nil || basis.currentURLRule == nil)+    }++    /// Whether one side may take a suggestion from this origin. The automatic+    /// path stays off taught, touched and dismissed sides (Reqs 1.1, 2.8, 5.8);+    /// the reader's request applies to any of them (Req 6.2).+    private func accepts(_ side: Side, origin: SuggestionApplication) -> Bool {+        guard origin == .automatic else { return true }+        if suggestions?.isDismissed(hostname: hostname, side: side) == true { return false }+        switch side {+        case .title: return retainedTitleRule == nil && !titleEdited+        case .url: return frozenBasis?.currentURLRule == nil && !urlTouched+        }+    }++    /// Seeds the title side through the same path a stored rule takes, then+    /// checks Req 3.5: what the chips now author must be the rule that was+    /// verified. Anything else puts the side back as it was and counts as no+    /// suggestion — the cannot-depict outcome of Req 1.4.+    ///+    /// The rollback is to the state the side was in **when this call started**,+    /// not to the pre-suggestion snapshot: on a re-apply over an already applied+    /// suggestion (a second Suggest after the hostname's hold was invalidated+    /// and recomputed) that state is the applied suggestion, and Req 6.2 /+    /// Req 1.4-on-request say a side with no valid suggestion is *left+    /// unchanged*. Restoring the pre-suggestion baseline there would silently+    /// undo a suggestion the reader had accepted, marker and all.+    private func applyTitleSuggestion(_ suggestion: TitleRuleSuggestion) -> Bool {+        let before = TitleSuggestionSnapshot(+            subdividedSegments: subdividedSegments, chips: titleChips, roles: titleRoles,+            edited: titleEdited, storedNotice: storedTitleRuleNotice)+        seedTitleEditor(+            definition: suggestion.definition,+            trimPrefix: suggestion.trimPrefix, trimSuffix: suggestion.trimSuffix)+        guard let authored = selectedTitleRule,+              RuleDefinitionComparator.semanticallyEqual(+                authored.definition, suggestion.definition),+              RuleDefinitionComparator.trimsEqual(authored.trimPrefix, suggestion.trimPrefix),+              RuleDefinitionComparator.trimsEqual(authored.trimSuffix, suggestion.trimSuffix)+        else {+            // Nothing else is touched: the applied flag, the applied suggestion+            // and the pre-suggestion snapshot all survive a failed re-apply.+            restoreTitle(before)+            return false+        }+        // The snapshot is the *pre-suggestion* baseline the clear action returns+        // to, so only the not-applied → applied transition establishes it.+        if !titleSuggestionApplied { titleSuggestionSnapshot = before }+        // Q34: a suggestion is not a stored rule, so seeding it must never raise+        // the stored-rule notice — Req 1.4 says an undepictable side is silent.+        storedTitleRuleNotice = nil+        appliedTitleSuggestion = suggestion+        return true+    }++    /// The URL side's cannot-depict check, run headlessly on the opened entry's+    /// components before anything is published: the same seed-then-read the+    /// editor performs, so a rule it could not reproduce never reaches it.+    private func applyURLSuggestion(_ definition: URLRuleDefinition) -> Bool {+        guard canDepictURLRule(definition) else { return false }+        if !urlSuggestionApplied {+            urlSuggestionSnapshot = URLSuggestionSnapshot(+                definition: urlRuleDefinition, status: urlRuleStatus, disclosure: disclosureState)+        }+        urlRuleStatus = .valid(definition)+        urlRuleDefinition = definition+        // The suggested rule is what the reader has to review, so the section+        // that shows it opens.+        disclosureState = .expanded+        appliedURLSuggestion = definition+        return true+    }++    private func canDepictURLRule(_ definition: URLRuleDefinition) -> Bool {+        guard let components = try? RawURLRuleParser.parse(ExactScalarString(exampleRawURL))+        else { return false }+        var state = ComposedTeachingPresentation.URLEditorState()+        state.seed(from: definition, in: components)+        guard case .valid(let authored) = state.rule(in: components).status else { return false }+        return RuleDefinitionComparator.semanticallyEqual(authored, definition)+    }++    private func isAppliedURLSuggestion(_ definition: URLRuleDefinition?) -> Bool {+        guard let definition, let applied = appliedURLSuggestion else { return false }+        return RuleDefinitionComparator.semanticallyEqual(definition, applied)+    }++    /// Retires one side's suggestion: the marker goes with the applied payload,+    /// the pre-suggestion snapshot is spent, and the side is dismissed for the+    /// rest of the run (Reqs 2.3, 2.7, 2.8).+    ///+    /// The three paths that reach it differ in one thing only. The clear action+    /// puts the editor back as it was; a direct edit on either side has already+    /// replaced that state with the reader's own, and restoring the snapshot+    /// there would undo the very edit that retired the suggestion.+    private func retireSuggestion(_ side: Side, restoringSnapshot: Bool) {+        switch side {+        case .title:+            if restoringSnapshot, let snapshot = titleSuggestionSnapshot { restoreTitle(snapshot) }+            titleSuggestionSnapshot = nil+            appliedTitleSuggestion = nil+        case .url:+            if restoringSnapshot { restoreURLSnapshot() }+            urlSuggestionSnapshot = nil+            appliedURLSuggestion = nil+        }+        suggestions?.dismiss(hostname: hostname, side: side)+    }++    /// Whether a suggestion is currently applied to one side.+    private func isSuggestionApplied(_ side: Side) -> Bool {+        switch side {+        case .title: titleSuggestionApplied+        case .url: urlSuggestionApplied+        }+    }++    /// Whether a suggestion carries anything for one side.+    private static func offers(_ suggestion: RuleSuggestion, _ side: Side) -> Bool {+        switch side {+        case .title: suggestion.title != nil+        case .url: suggestion.url != nil+        }+    }++    private func restoreTitle(_ snapshot: TitleSuggestionSnapshot) {+        subdividedSegments = snapshot.subdividedSegments+        titleChips = snapshot.chips+        titleRoles = snapshot.roles+        titleEdited = snapshot.edited+        storedTitleRuleNotice = snapshot.storedNotice+    }++    private func restoreURLSnapshot() {+        guard let snapshot = urlSuggestionSnapshot else { return }+        urlRuleDefinition = snapshot.definition+        urlRuleStatus = snapshot.status+        disclosureState = snapshot.disclosure+    }++    /// Req 2.7: a side whose suggestion is held or applied, saved as something+    /// else, is dismissed for the rest of the run.+    private func dismissDivergentlySavedSides() {+        guard let suggestions, let request = contract?.request else { return }+        let held = suggestions.held(for: hostname)+        if let title = appliedTitleSuggestion ?? held?.title {+            let same = RuleDefinitionComparator.semanticallyEqual(+                request.titleDefinition, title.definition)+                && RuleDefinitionComparator.trimsEqual(request.trimPrefix, title.trimPrefix)+                && RuleDefinitionComparator.trimsEqual(request.trimSuffix, title.trimSuffix)+            if !same { suggestions.dismiss(hostname: hostname, side: .title) }+        }+        if let url = appliedURLSuggestion ?? held?.url {+            let same = request.urlDefinition+                .map { RuleDefinitionComparator.semanticallyEqual($0, url) } ?? false+            if !same { suggestions.dismiss(hostname: hostname, side: .url) }+        }+    }+     // MARK: - Acknowledgment (Req 2.1, Q3)      /// Records the per-commit unsettled-chapters acknowledgment and commits. The@@ -878,6 +1265,11 @@ public final class ComposedTeachingViewModel {     public func cancel() {         previewTask?.cancel()         previewTask = nil+        // The delivery is cancelled, never the attempt: it is the coordinator's+        // and its result is still worth holding (Req 5.7).+        suggestionTask?.cancel()+        suggestionTask = nil+        suggestionBusy = false         state = .cancelled     } @@ -1043,6 +1435,10 @@ public final class ComposedTeachingViewModel {     private func handleCommitOutcome(_ outcome: ComposedTeachingCommitOutcome) {         switch outcome {         case .committed:+            // Req 2.7's third trigger, read before the contract is let go: a+            // saved rule that is not the suggestion is the reader choosing+            // against it. An equal save is Req 2.6 and dismisses nothing.+            dismissDivergentlySavedSides()             committedOutcome = previewOutcome             requiresReconfirmation = false             state = .committed@@ -1129,6 +1525,11 @@ public final class ComposedTeachingViewModel {     // MARK: - Selection helpers      private func commitTitleEdit() {+        // Req 2.7: a direct edit retires the marker and dismisses the side for+        // the rest of the run.+        if titleSuggestionApplied {+            retireSuggestion(.title, restoringSnapshot: false)+        }         titleEdited = true         titleSelectionNotice = nil         // The stored rule stops being the one in effect the moment the reader@@ -1174,18 +1575,34 @@ public final class ComposedTeachingViewModel {     /// case `retainedTitleRule` keeps the commit faithful until the reader     /// actually edits the title.     private func seedTitleEditor(from rule: ComposedTitleRuleBasis) {-        switch rule.definition {+        seedTitleEditor(+            definition: rule.definition, trimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix)+    }++    /// The seeding itself, taking only the three fields it reads.+    ///+    /// A suggested title rule is seeded through exactly this path (Q14): what+    /// the reader sees for a suggestion and for a stored rule must come from+    /// one implementation, cannot-depict fallback included, or Req 1.4's "the+    /// same cannot-depict fallback a stored rule has" is two behaviours.+    func seedTitleEditor(+        definition: PatternDefinition, trimPrefix: String?, trimSuffix: String?+    ) {+        switch definition {         case .wholeTitle:-            let span = keptSpan(forTrimPrefix: rule.trimPrefix, trimSuffix: rule.trimSuffix)+            let span = keptSpan(forTrimPrefix: trimPrefix, trimSuffix: trimSuffix)             applySelection(workSpan: span, chapterSpan: nil)         case .segment(let work, let ignored):             applySegmentSelection(-                rule: rule, work: work, ignored: ignored, chapterFromRemainder: true)+                definition: definition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,+                work: work, ignored: ignored, chapterFromRemainder: true)         case .chapterlessSegment(let work, let ignored):             applySegmentSelection(-                rule: rule, work: work, ignored: ignored, chapterFromRemainder: false)+                definition: definition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,+                work: work, ignored: ignored, chapterFromRemainder: false)         case .phrase, .chapterlessPhrase:-            if let spans = phraseSpans(for: rule) {+            if let spans = phraseSpans(+                definition: definition, trimPrefix: trimPrefix, trimSuffix: trimSuffix) {                 applySelection(workSpan: spans.work, chapterSpan: spans.chapter)             }         }@@ -1203,12 +1620,14 @@ public final class ComposedTeachingViewModel {     }      private func applySegmentSelection(-        rule: ComposedTitleRuleBasis, work: SegmentRangeSpec, ignored: [SegmentPositionSpec],+        definition: PatternDefinition, trimPrefix: String?, trimSuffix: String?,+        work: SegmentRangeSpec, ignored: [SegmentPositionSpec],         chapterFromRemainder: Bool     ) {-        guard rule.trimPrefix == nil, rule.trimSuffix == nil else {+        guard trimPrefix == nil, trimSuffix == nil else {             applyTrimmedSegmentSelection(-                rule: rule, work: work, ignored: ignored, chapterFromRemainder: chapterFromRemainder)+                definition: definition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,+                work: work, ignored: ignored, chapterFromRemainder: chapterFromRemainder)             return         }         guard let restored = Self.roles(@@ -1225,11 +1644,12 @@ public final class ComposedTeachingViewModel {     /// 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],+        definition: PatternDefinition, trimPrefix: String?, trimSuffix: String?,+        work: SegmentRangeSpec, ignored: [SegmentPositionSpec],         chapterFromRemainder: Bool     ) {         guard let selection = trimmedSegmentSelection(-                rule: rule, work: work, ignored: ignored,+                trimPrefix: trimPrefix, trimSuffix: trimSuffix, work: work, ignored: ignored,                 chapterFromRemainder: chapterFromRemainder),               let inferred = ComposedTeachingPresentation.inferredTitleRule(                 title: exampleTitle, segments: titleSegments,@@ -1238,9 +1658,9 @@ public final class ComposedTeachingViewModel {               // 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)+              RuleDefinitionComparator.semanticallyEqual(inferred.definition, definition),+              RuleDefinitionComparator.trimsEqual(inferred.trimPrefix, trimPrefix),+              RuleDefinitionComparator.trimsEqual(inferred.trimSuffix, trimSuffix)         else {             storedTitleRuleNotice = ComposedTeachingPresentation.storedTitleRuleNotice             return@@ -1256,24 +1676,25 @@ public final class ComposedTeachingViewModel {     /// 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],+        trimPrefix: String?, trimSuffix: String?,+        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)+                prefix: trimPrefix, suffix: trimSuffix, in: exampleTitle)         else { return nil }         let lastIndex = titleSegments.count - 1          var subdivided: Set<Int> = []-        if rule.trimPrefix != nil {+        if 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 {+        if trimSuffix != nil {             guard kept.upperBound > last.range.lowerBound,                   partBoundaries(ofSegment: lastIndex).ends.contains(kept.upperBound) else { return nil }             subdivided.insert(lastIndex)@@ -1284,7 +1705,7 @@ public final class ComposedTeachingViewModel {         // 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)+            prefix: trimPrefix, suffix: trimSuffix, to: exampleTitle)         let trimmedSegments = ComposedTeachingPresentation.titleSegments(in: trimmed)         guard trimmedSegments.count == titleSegments.count,               let segmentRoles = Self.roles(@@ -1322,13 +1743,15 @@ public final class ComposedTeachingViewModel {     /// rule still parses it. `.phrase` is `prefix + FIELD + separator + FIELD +     /// suffix`, so the parsed field lengths locate both spans exactly; a title the     /// rule no longer parses leaves the selection at its default.-    private func phraseSpans(for rule: ComposedTitleRuleBasis) -> (work: Range<Int>, chapter: Range<Int>)? {-        guard case .phrase(let prefix, _, let suffix, let order) = rule.definition,+    private func phraseSpans(+        definition: PatternDefinition, trimPrefix: String?, trimSuffix: String?+    ) -> (work: Range<Int>, chapter: Range<Int>)? {+        guard case .phrase(let prefix, _, let suffix, let order) = definition,               case .success(let parsed) = TitleRuleApplicator.apply(-                definition: rule.definition, trimPrefix: rule.trimPrefix,-                trimSuffix: rule.trimSuffix, to: entry.captureTitle),+                definition: definition, trimPrefix: trimPrefix,+                trimSuffix: trimSuffix, to: entry.captureTitle),               let chapterTitle = parsed.chapterTitle,-              rule.trimPrefix == nil, rule.trimSuffix == nil else { return nil }+              trimPrefix == nil, trimSuffix == nil else { return nil }          let characters = Array(entry.captureTitle)         let count = characters.count
Asterism/Asterism/Views/ComposedTeachingPresentation.swift Modified +18 / -8
diff --git a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift b/Asterism/Asterism/Views/ComposedTeachingPresentation.swiftindex ff98579..4f9e5b6 100644--- a/Asterism/Asterism/Views/ComposedTeachingPresentation.swift+++ b/Asterism/Asterism/Views/ComposedTeachingPresentation.swift@@ -211,7 +211,8 @@ public enum ComposedTeachingPresentation {      /// Character ranges of the maximal alphanumeric runs in a component — the     /// tappable tokens of the split editor. Everything between is separator text.-    public static func tokenRanges(in componentText: String) -> [Range<Int>] {+    /// `nonisolated`: reached from the nonisolated `titleChips`, and pure.+    public nonisolated static func tokenRanges(in componentText: String) -> [Range<Int>] {         var ranges: [Range<Int>] = []         var runStart: Int?         for (offset, character) in componentText.enumerated() {@@ -289,7 +290,10 @@ public enum ComposedTeachingPresentation {     // MARK: - Two-granularity title chip selection (Req 3.3, 8.3, 8.6)      /// A delimiter-split segment of the example title with its character range.-    public struct TitleSegment: Equatable, Sendable {+    /// `nonisolated`: the app target defaults to main-actor isolation, and the+    /// synthesized `==` has to be usable off the main actor — `RuleSuggestionAssembler`+    /// compares these values on the suggester actor.+    public nonisolated struct TitleSegment: Equatable, Sendable {         public let text: String         public let range: Range<Int>     }@@ -297,7 +301,8 @@ public enum ComposedTeachingPresentation {     /// One tappable title chip: a whole delimiter-split **segment** by default,     /// or one **part** (maximal alphanumeric run) of a segment the reader has     /// subdivided in place.-    public struct TitleChip: Equatable, Sendable {+    /// `nonisolated` for the same reason as `TitleSegment`.+    public nonisolated struct TitleChip: Equatable, Sendable {         public let text: String         /// Character range within the example title.         public let range: Range<Int>@@ -310,7 +315,8 @@ public enum ComposedTeachingPresentation {      /// The title rule a chip selection authors. The form is inferred from the     /// selection and never chosen by the reader (Req 8.6).-    public struct InferredTitleRule: Equatable, Sendable {+    /// `nonisolated` for the same reason as `TitleSegment`.+    public nonisolated struct InferredTitleRule: Equatable, Sendable {         public let definition: PatternDefinition         public let trimPrefix: String?         public let trimSuffix: String?@@ -357,7 +363,8 @@ public enum ComposedTeachingPresentation {     /// The chip row for a set of subdivided segment indices. Subdividing happens     /// in place: the subdivided segment's chip is replaced by its part chips in     /// the same row (Req 8.3 — no sheet, no second screen).-    public static func titleChips(segments: [TitleSegment], subdividing: Set<Int>) -> [TitleChip] {+    /// `nonisolated`: reached from the nonisolated `titleSelection`, and pure.+    public nonisolated static func titleChips(segments: [TitleSegment], subdividing: Set<Int>) -> [TitleChip] {         var chips: [TitleChip] = []         for (index, segment) in segments.enumerated() {             let parts = tokenRanges(in: segment.text)@@ -403,7 +410,8 @@ public enum ComposedTeachingPresentation {     /// 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(+    /// `nonisolated`: called off the main actor by `RuleSuggestionAssembler`, and pure.+    public nonisolated static func inferredTitleRule(         title: String, segments: [TitleSegment], chips: [TitleChip], roles: [SegmentRole]     ) -> InferredTitleRule? {         inferenceOutcome(title: title, segments: segments, chips: chips, roles: roles).rule@@ -514,7 +522,8 @@ public enum ComposedTeachingPresentation {     /// The chip selection that reproduces a Work span and an optional chapter     /// span — used to re-seed the selector from the rule a Site already holds.     /// Segments a span cuts through are subdivided so the boundary is expressible.-    public static func titleSelection(+    /// `nonisolated`: called off the main actor by `RuleSuggestionAssembler`, and pure.+    public nonisolated static func titleSelection(         in title: String, workSpan: Range<Int>, chapterSpan: Range<Int>?     ) -> (subdivided: Set<Int>, roles: [SegmentRole]) {         let segments = titleSegments(in: title)@@ -709,7 +718,8 @@ public enum ComposedTeachingPresentation {     }      /// Whether a span's boundary falls strictly inside a segment.-    private static func splits(_ segment: Range<Int>, by span: Range<Int>) -> Bool {+    /// `nonisolated`: called from the nonisolated `titleSelection`, and pure.+    private nonisolated static func splits(_ segment: Range<Int>, by span: Range<Int>) -> Bool {         (span.lowerBound > segment.lowerBound && span.lowerBound < segment.upperBound)             || (span.upperBound > segment.lowerBound && span.upperBound < segment.upperBound)     }
Asterism/Asterism/Views/ComposedTeachingView.swift Modified +138 / -0
diff --git a/Asterism/Asterism/Views/ComposedTeachingView.swift b/Asterism/Asterism/Views/ComposedTeachingView.swiftindex d7681e7..15033c6 100644--- a/Asterism/Asterism/Views/ComposedTeachingView.swift+++ b/Asterism/Asterism/Views/ComposedTeachingView.swift@@ -1,4 +1,5 @@ import AsterismCore+import AsterismIntelligence import ConstellationKit import SwiftUI @@ -88,6 +89,10 @@ struct ComposedTeachingView: View {         if model.articlesRequested {             articlesConfirmation         } else {+            // rule-suggestion Req 6.1: the on-request action is the first+            // actionable thing on the screen, above the selection it fills in —+            // beside the commit control it went unnoticed.+            suggestRow             titleChipSelector             urlDisclosure             if let outcome = model.previewOutcome {@@ -154,6 +159,14 @@ struct ComposedTeachingView: View {                     .accessibilityIdentifier("composed-title-stored-rule-notice")             } +            if model.titleSuggestionApplied {+                suggestionMarker(+                    voiceOverLabel: "Suggested title rule",+                    identifier: "composed-title-suggested",+                    clearIdentifier: "composed-title-suggested-clear",+                    clear: { model.clearSuggestedSide(.title) })+            }+             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.@@ -190,6 +203,110 @@ struct ComposedTeachingView: View {         .frame(maxWidth: .infinity, alignment: .leading)     } +    // MARK: - Rule suggestion (rule-suggestion Reqs 2.1, 2.3, 6.1, 6.3, 6.4)++    /// One side's "this was suggested" marker with its clear action, in the same+    /// amber caption register as the editor's other notices.+    ///+    /// The row keeps its children (`.contain`), or the clear button is+    /// unqueryable from XCUITest and unreachable from VoiceOver+    /// (`docs/agent-notes/composed-teaching-ui.md`). The marker's own identifier+    /// goes on the **label**, not on the row: inside the URL disclosure, a+    /// container element one level under the `DisclosureGroup` is merged with+    /// the group's element and takes its `composed-url-disclosure` identifier,+    /// so a row-level identifier there is silently lost. Leaves keep theirs.+    private func suggestionMarker(+        voiceOverLabel: String, identifier: String, clearIdentifier: String,+        clear: @escaping () -> Void+    ) -> some View {+        HStack(spacing: 8) {+            suggestionMarkerLabel(voiceOverLabel: voiceOverLabel, identifier: identifier)+            Spacer(minLength: 0)+            Button("Clear", action: clear)+                .font(.footnote)+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier(clearIdentifier)+                .accessibilityLabel("Clear \(voiceOverLabel.lowercased())")+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .accessibilityElement(children: .contain)+    }++    /// The marker itself: one combined element, so the symbol and the caption+    /// read as a single VoiceOver stop and one identifier addresses it.+    private func suggestionMarkerLabel(+        voiceOverLabel: String, identifier: String+    ) -> some View {+        Label("Suggested — review before saving", systemImage: "sparkles")+            .font(.caption)+            .foregroundStyle(AsterismColors.amberText)+            .accessibilityElement(children: .combine)+            .accessibilityLabel(voiceOverLabel)+            .accessibilityIdentifier(identifier)+    }++    /// Req 6.1: shown only while the on-device model is there and the Site is+    /// one a rule can be taught for. It heads the editor, so it is the first+    /// actionable control the reader meets — a full-width secondary button, kept+    /// visually distinct from the one gradient control the commit wears.+    ///+    /// Busy disables the button and shows a `ProgressView` inside the capsule+    /// (Req 6.3); a suggestion that arrived too late to be applied says so under+    /// it with the same `sparkles` the marker uses (Req 5.9), and Req 6.4's+    /// notice sits under it too, where the tap happened.+    ///+    /// The spinner is an **overlay**, not part of the button's label: a+    /// `Button` merges its label into one element, which would swallow the+    /// `composed-suggest-busy` identifier+    /// (`docs/agent-notes/composed-teaching-ui.md`).+    @ViewBuilder+    private var suggestRow: some View {+        if model.offersSuggestAction {+            VStack(alignment: .leading, spacing: 8) {+                Button {+                    Task { await model.requestSuggestion() }+                } label: {+                    Label("Suggest rule", systemImage: "sparkles")+                        .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)+                }+                .buttonStyle(.constellationSecondary)+                .disabled(model.suggestionBusy)+                .accessibilityIdentifier("composed-suggest")+                .overlay(alignment: .trailing) {+                    if model.suggestionBusy {+                        ProgressView()+                            .controlSize(.small)+                            .padding(.trailing, 18)+                            .accessibilityIdentifier("composed-suggest-busy")+                            .accessibilityLabel("Working on a suggestion")+                    }+                }++                if model.suggestionReady {+                    Label("A suggestion is ready", systemImage: "sparkles")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.amberText)+                        .accessibilityElement(children: .combine)+                        .accessibilityLabel("A suggestion is ready")+                        .accessibilityIdentifier("composed-suggest-ready")+                }++                if let notice = model.suggestionUnavailableNotice {+                    // rule-suggestion Req 6.4: the one thing about suggestions+                    // the reader is ever told, in the same amber caption+                    // register as the editor's other notices.+                    Label(notice, systemImage: "exclamationmark.circle")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.amberText)+                        .accessibilityIdentifier("composed-suggest-unavailable")+                }+            }+            .frame(maxWidth: .infinity, alignment: .leading)+            .accessibilityElement(children: .contain)+            .accessibilityIdentifier("composed-suggest-row")+        }+    }+     // MARK: - Articles affordance (Req 8.7)      /// Articles is a one-way Site transition, not a title rule form, so it sits@@ -265,6 +382,13 @@ struct ComposedTeachingView: View {                 set: { model.setURLDisclosureExpanded($0) })         ) {             VStack(alignment: .leading, spacing: 12) {+                if model.urlSuggestionApplied {+                    suggestionMarker(+                        voiceOverLabel: "Suggested URL rule",+                        identifier: "composed-url-suggested",+                        clearIdentifier: "composed-url-suggested-clear",+                        clear: { model.clearSuggestedSide(.url) })+                }                 // The editor owns the single clear affordance                 // ("composed-url-clear"); a second one here would clear the                 // definition without resetting the editor's own selections.@@ -308,6 +432,20 @@ struct ComposedTeachingView: View {                     Text(ComposedTeachingPresentation.disclosureBenefit)                         .font(.caption).foregroundStyle(.secondary)                 }+                if model.urlSuggestionApplied, !model.urlDisclosureExpanded {+                    // Req 2.1: the marker is visible for as long as the+                    // suggestion is applied, and while the section is closed+                    // this row is all there is of the URL side. Gated on the+                    // same condition that hides the section's own marker, so+                    // exactly one carries the identifier at any moment.+                    //+                    // The clear action stays inside the section: a button in a+                    // DisclosureGroup's label competes with the header's own tap+                    // target, and Req 2.3 asks for one action, not two.+                    suggestionMarkerLabel(+                        voiceOverLabel: "Suggested URL rule",+                        identifier: "composed-url-suggested")+                }             }         }         .accessibilityIdentifier("composed-url-disclosure")
Asterism/Asterism/Views/ComposedURLEditorState.swift Modified +13 / -0
diff --git a/Asterism/Asterism/Views/ComposedURLEditorState.swift b/Asterism/Asterism/Views/ComposedURLEditorState.swiftindex 3618cef..971ab9f 100644--- a/Asterism/Asterism/Views/ComposedURLEditorState.swift+++ b/Asterism/Asterism/Views/ComposedURLEditorState.swift@@ -507,6 +507,19 @@ extension ComposedTeachingPresentation {             split = ComposedTeachingPresentation.defaultSplitSelection(for: componentText)         } +        /// Set the within-component split outright, without going through the+        /// reader's token gestures (Q36).+        ///+        /// `RuleSuggestionAssembler` has two character spans in one component+        /// and no gestures to make; `beginSplit` + repeated `toggleSplitToken`+        /// cannot express an arbitrary pair. The split itself is all that is+        /// set: `rule(in:)` treats a live split as superseding anything+        /// retained and re-derives the template from it, exactly as it does+        /// after a token tap.+        mutating func setSplit(_ selection: URLTwoFieldSelection) {+            split = selection+        }+         /// A token tap inside the split editor.         public mutating func toggleSplitToken(at index: Int, tokens: [Range<Int>]) {             guard let current = split else { return }
Asterism/AsterismTests/ComposedTeachingViewModelTests.swift Modified +498 / -4
diff --git a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swiftindex 6ba5f53..502b7d2 100644--- a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift+++ b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift@@ -1,4 +1,5 @@ import AsterismCore+import AsterismIntelligence import Foundation import Testing @testable import Asterism@@ -17,9 +18,11 @@ struct ComposedTeachingViewModelTests {      private static func makeEntry(         title: String = "TtH - Story - Real Title",-        hostname: String = ComposedTeachingViewModelTests.hostname+        hostname: String = ComposedTeachingViewModelTests.hostname,+        rawURLString: String? = nil     ) -> EntrySnapshot {-        TestFixtures.makeEntry(captureTitle: title, hostname: hostname)+        TestFixtures.makeEntry(+            captureTitle: title, hostname: hostname, rawURLString: rawURLString)     }      private static func makeOutcome(requiresAck: Bool = false) -> ComposedTeachingOutcome {@@ -67,7 +70,8 @@ struct ComposedTeachingViewModelTests {         commitOutcome: ComposedTeachingCommitOutcome = .committed(             titleRuleID: UUID(), titleRuleVersion: 1, urlRuleID: nil, urlRuleVersion: nil),         entryContext: ComposedTeachingViewModel.EntryContext = .titleFocused,-        entry: EntrySnapshot? = nil+        entry: EntrySnapshot? = nil,+        suggestions: RuleSuggestionCoordinator? = nil     ) -> (ComposedTeachingViewModel, MockLibraryProvider) {         let mock = MockLibraryProvider()         let c = contract ?? Self.makeContract()@@ -77,7 +81,7 @@ struct ComposedTeachingViewModelTests {         mock.commitArticlesResult = .success(.committed)         let vm = ComposedTeachingViewModel(             entry: entry ?? Self.makeEntry(), library: mock, capabilities: .m4,-            entryContext: entryContext, onMutation: {})+            entryContext: entryContext, onMutation: {}, suggestions: suggestions)         return (vm, mock)     } @@ -1016,4 +1020,494 @@ struct ComposedTeachingViewModelTests {         #expect(request.titleDefinition == expected)         #expect(request.titleDefinition != definition)     }++    // MARK: - Rule suggestion (Reqs 1, 2, 3.5, 5.7–5.9, 6)++    /// The suggested title rule for `TtH - Story - Real Title`: the last segment+    /// names the Work, the rest is the chapter. Seeding it re-derives exactly+    /// this definition, which is what Req 3.5 demands of a suggestion.+    private static func suggestedTitle(+        trimPrefix: String? = nil+    ) throws -> TitleRuleSuggestion {+        TitleRuleSuggestion(+            definition: .segment(+                work: try SegmentRangeSpec(origin: .end, offset: 0, length: 1), ignored: []),+            trimPrefix: trimPrefix)+    }++    /// A URL rule the editor can depict on the fixture URL below: a query item+    /// carries its own name, so seeding it needs no anchoring.+    nonisolated private static let suggestedURL = URLRuleDefinition.sequence(+        locator: .query(name: ExactScalarString("chapter")))++    /// The capture the suggestion tests open the editor from — three title+    /// segments and a query-bearing URL.+    private static func suggestionEntry() -> EntrySnapshot {+        makeEntry(+            title: "TtH - Story - Real Title",+            rawURLString: "https://example.com/read?id=42&chapter=7")+    }++    private static func suggestion(+        title: TitleRuleSuggestion? = nil, url: URLRuleDefinition? = nil+    ) -> RuleSuggestion {+        RuleSuggestion(hostname: hostname, title: title, url: url)+    }++    /// A coordinator over the stubbed attempt seam. `held` is delivered through+    /// a settled background attempt, which is the only way the ledger holds a+    /// suggestion; `delivers` is left for the editor's own on-open attempt.+    private func makeCoordinator(+        held: RuleSuggestion? = nil,+        delivers: RuleSuggestion? = nil,+        availability: ModelAvailability = .available+    ) async -> (RuleSuggestionCoordinator, RuleSuggestionCoordinatorTests.StubSuggester) {+        let mock = MockLibraryProvider()+        mock.ruleSuggestionCandidatesResult = .success([+            RuleSuggestionCandidate(+                hostname: Self.hostname, siteMode: .untaught, titleRuleVersion: nil,+                urlRuleVersion: nil, entryCount: 3,+                latestCaptureAt: Date(timeIntervalSince1970: 1)),+        ])+        let suggester = RuleSuggestionCoordinatorTests.StubSuggester()+        let coordinator = RuleSuggestionCoordinator(+            library: mock,+            model: StubRuleSuggestionModelClient(availability: availability),+            suggester: suggester,+            environment: RuleSuggestionCoordinatorTests.StubEnvironment())+        if let held {+            suggester.suggestions[Self.hostname] = held+            _ = await coordinator.suggestion(for: Self.hostname, origin: .background)+            suggester.suggestions[Self.hostname] = nil+        }+        suggester.suggestions[Self.hostname] = delivers+        return (coordinator, suggester)+    }++    /// Spins the main actor until the condition holds. The on-open delivery is a+    /// child task, so there is something to wait for but nothing to sleep on.+    private func yieldUntil(_ condition: () -> Bool) async {+        var spins = 0+        while !condition(), spins < 200 {+            await Task.yield()+            spins += 1+        }+    }++    @Test("A held suggestion seeds both untaught sides and marks them")+    func heldSuggestionSeedsUntaughtSides() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()++        #expect(vm.titleSuggestionApplied)+        #expect(vm.urlSuggestionApplied)+        #expect(vm.titleRoles == [.chapter, .chapter, .work])+        #expect(vm.urlRuleDefinition == Self.suggestedURL)+        #expect(vm.effectiveTitleRule?.definition == held.title?.definition)+        // Req 2.5: nothing was written because a suggestion was applied.+        #expect(vm.state != .committed)+    }++    @Test("The retained rule wins on a taught side; the untaught side still seeds")+    func retainedRuleWinsOnTaughtSide() async throws {+        let retained = PatternDefinition.wholeTitle+        let contract = Self.makeContract(+            currentTitleRule: Self.titleRuleBasis(definition: retained))+        let held = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(+            contract: contract, entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()++        #expect(!vm.titleSuggestionApplied)+        #expect(vm.effectiveTitleRule?.definition == retained)+        #expect(vm.urlSuggestionApplied)+    }++    @Test("A dismissed side is not prefilled by the automatic path (Req 2.8)")+    func dismissedSideIsNotPrefilled() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        coordinator.dismiss(hostname: Self.hostname, side: .title)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()++        #expect(!vm.titleSuggestionApplied)+        #expect(vm.urlSuggestionApplied)+    }++    @Test("Editing the title clears its marker and dismisses that side (Req 2.7)")+    func titleEditDismissesTheTitleSide() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        #expect(vm.titleSuggestionApplied)++        vm.cycleTitleRole(at: 0)++        #expect(!vm.titleSuggestionApplied)+        #expect(coordinator.isDismissed(hostname: Self.hostname, side: .title))+        #expect(!coordinator.isDismissed(hostname: Self.hostname, side: .url))+    }++    @Test("A differing URL outcome clears its marker and dismisses that side (Q47)")+    func urlEditDismissesTheURLSide() async throws {+        let held = Self.suggestion(url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        #expect(vm.urlSuggestionApplied)++        vm.setURLRuleDefinition(.sequence(locator: .query(name: ExactScalarString("id"))))++        #expect(!vm.urlSuggestionApplied)+        #expect(coordinator.isDismissed(hostname: Self.hostname, side: .url))+    }++    @Test("The URL editor re-publishing the same rule is not an edit")+    func equalURLOutcomeDoesNotDismiss() async throws {+        let held = Self.suggestion(url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()++        vm.setURLRuleDefinition(Self.suggestedURL)++        #expect(vm.urlSuggestionApplied)+        #expect(!coordinator.isDismissed(hostname: Self.hostname, side: .url))+    }++    @Test("Clearing a suggested side restores the untaught initial state and dismisses it")+    func clearRestoresUntaughtState() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()++        vm.clearSuggestedSide(.title)+        vm.clearSuggestedSide(.url)++        #expect(!vm.titleSuggestionApplied)+        #expect(!vm.urlSuggestionApplied)+        // The untaught initial state: whole title, no URL selection.+        #expect(vm.titleRoles == [.work, .work, .work])+        #expect(vm.urlRuleDefinition == nil)+        #expect(vm.urlRuleStatus == .cleared)+        #expect(coordinator.isDismissed(hostname: Self.hostname, side: .title))+        #expect(coordinator.isDismissed(hostname: Self.hostname, side: .url))+    }++    @Test("Clearing a suggested side on a taught site restores the retained rule (Q42)")+    func clearRestoresRetainedRule() async throws {+        let retained = PatternDefinition.wholeTitle+        let contract = Self.makeContract(+            currentTitleRule: Self.titleRuleBasis(definition: retained))+        let (coordinator, _) = await makeCoordinator(+            held: Self.suggestion(title: try Self.suggestedTitle()))+        let (vm, _) = makeSUT(+            contract: contract, entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        // Only the request path reaches a taught side.+        await vm.requestSuggestion()+        #expect(vm.titleSuggestionApplied)++        vm.clearSuggestedSide(.title)++        #expect(!vm.titleSuggestionApplied)+        #expect(vm.effectiveTitleRule?.definition == retained)+    }++    @Test("The pre-suggestion snapshot is taken once, so a re-apply keeps the original baseline")+    func snapshotIsTakenOnce() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        #expect(vm.titleRoles == [.chapter, .chapter, .work])++        // A second application of the same held suggestion must not snapshot the+        // suggested selection as if it were the pre-suggestion state.+        await vm.requestSuggestion()+        vm.clearSuggestedSide(.title)++        #expect(vm.titleRoles == [.work, .work, .work])+    }++    @Test("effectiveTitleRule follows the suggestion once it is applied on a taught side (Q33)")+    func effectiveRuleFollowsSuggestionOnTaughtSide() async throws {+        let retained = PatternDefinition.wholeTitle+        let contract = Self.makeContract(+            currentTitleRule: Self.titleRuleBasis(definition: retained))+        let suggested = try Self.suggestedTitle()+        let (coordinator, _) = await makeCoordinator(held: Self.suggestion(title: suggested))+        let (vm, mock) = makeSUT(+            contract: contract, entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()++        await vm.requestSuggestion()+        try await Task.sleep(for: .milliseconds(30))++        #expect(vm.effectiveTitleRule?.definition == suggested.definition)+        let request = try #require(mock.lastProjectComposedRequest)+        #expect(request.titleDefinition == suggested.definition)+    }++    @Test("A rule the opened capture cannot depict restores the snapshot and raises no notice")+    func cannotDepictRestoresTheSnapshot() async throws {+        // The trim is absent from this title, so seeding falls back to the+        // default selection and would ordinarily raise the stored-rule notice.+        let undepictable = try Self.suggestedTitle(trimPrefix: "Read ")+        let (coordinator, _) = await makeCoordinator(+            held: Self.suggestion(title: undepictable, url: Self.suggestedURL))+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()++        #expect(!vm.titleSuggestionApplied)+        #expect(vm.storedTitleRuleNotice == nil)+        #expect(vm.titleRoles == [.work, .work, .work])+        // Req 1.3: the other side is unaffected.+        #expect(vm.urlSuggestionApplied)+    }++    @Test("A re-apply that cannot be depicted leaves the applied side exactly as it was")+    func undepictableReapplyLeavesTheAppliedSideUnchanged() async throws {+        let applied = try Self.suggestedTitle()+        let (coordinator, _) = await makeCoordinator(held: Self.suggestion(title: applied))+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        #expect(vm.titleSuggestionApplied)+        #expect(vm.titleRoles == [.chapter, .chapter, .work])++        // A second suggestion for the same side — the hostname's hold was+        // recomputed after a save — whose trim is absent from this title, so it+        // cannot be depicted. Req 6.2 / Req 1.4 on request: left unchanged.+        let undepictable = Self.suggestion(title: try Self.suggestedTitle(trimPrefix: "Read "))+        let took = await vm.applySuggestion(undepictable, origin: .request)++        #expect(took.isEmpty)+        #expect(vm.titleSuggestionApplied)+        #expect(vm.titleRoles == [.chapter, .chapter, .work])+        #expect(vm.effectiveTitleRule?.definition == applied.definition)+        #expect(vm.storedTitleRuleNotice == nil)+        // The pre-suggestion baseline survived the failed re-apply, so the clear+        // action still returns the side to the untaught initial state (Req 2.3).+        vm.clearSuggestedSide(.title)+        #expect(vm.titleRoles == [.work, .work, .work])+    }++    /// Req 5.7's "no held suggestion for **any of its untaught sides**": a hold+    /// that only covers the taught side fills nothing, so the open falls through+    /// to the on-open request rather than returning satisfied. The coordinator+    /// then refuses it — the hold's own attempt is this run's attempt for the+    /// hostname — which is why the URL side stays untaught here.+    @Test("A hold covering only a taught side leaves the untaught side untouched (Req 5.7)")+    func holdOverATaughtSideLeavesTheUntaughtSideAlone() async throws {+        let contract = Self.makeContract(+            currentTitleRule: Self.titleRuleBasis(definition: .wholeTitle))+        let (coordinator, suggester) = await makeCoordinator(+            held: Self.suggestion(title: try Self.suggestedTitle()))+        let (vm, _) = makeSUT(+            contract: contract, entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()++        #expect(!vm.titleSuggestionApplied)+        #expect(!vm.urlSuggestionApplied)+        #expect(vm.urlRuleDefinition == nil)+        // Req 5.9: the hold exists but nothing took it, so the action says ready.+        #expect(vm.suggestionReady)+        #expect(suggester.calls == [Self.hostname])+    }++    @Test("Applying and then clearing leaves the ready indicator reading the hold (Q68)")+    func clearingASideRetiresItFromTheReadyIndicator() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()+        // Both sides took, so there is nothing left for the action to offer.+        #expect(vm.titleSuggestionApplied)+        #expect(vm.urlSuggestionApplied)+        #expect(!vm.suggestionReady)++        // The hold still carries a title side after the clear, but the reader+        // has just rejected it — the action must not advertise it as ready.+        vm.clearSuggestedSide(.title)+        #expect(!vm.titleSuggestionApplied)+        #expect(coordinator.isDismissed(hostname: Self.hostname, side: .title))+        #expect(!vm.suggestionReady)++        vm.clearSuggestedSide(.url)+        #expect(!vm.suggestionReady)+    }++    @Test("A side rejected earlier in the run does not light the ready indicator")+    func dismissedSideIsNotReady() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(held: held)+        // The reader rejected the title side in an earlier editor session; the+        // dismissal outlives the editor (Q18).+        coordinator.dismiss(hostname: Self.hostname, side: .title)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()++        #expect(!vm.titleSuggestionApplied)+        #expect(vm.urlSuggestionApplied)+        // The unapplied title side is unapplied because the reader said so, so+        // Req 5.9's indicator stays dark rather than nagging with it.+        #expect(!vm.suggestionReady)+    }++    @Test("A cancel while a request is in flight leaves the editor cancelled")+    func cancelDuringARequestIsNotOverwritten() async throws {+        let (coordinator, suggester) = await makeCoordinator()+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        // The editor's own on-open attempt settles with nothing first.+        await yieldUntil { !vm.suggestionBusy }++        suggester.isGated = true+        suggester.suggestions[Self.hostname] = Self.suggestion(title: try Self.suggestedTitle())+        let request = Task { await vm.requestSuggestion() }+        await yieldUntil { vm.suggestionBusy }+        vm.cancel()+        suggester.openGate()+        await request.value++        #expect(vm.state == .cancelled)+        #expect(!vm.titleSuggestionApplied)+    }++    @Test("A result arriving within the auto-apply window seeds untouched sides only")+    func lateResultWithinWindowAppliesUntouchedSides() async throws {+        let delivered = Self.suggestion(title: try Self.suggestedTitle(), url: Self.suggestedURL)+        let (coordinator, _) = await makeCoordinator(delivers: delivered)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()+        // Touch the URL side before the result lands.+        vm.setURLRuleDefinition(.sequence(locator: .query(name: ExactScalarString("id"))))+        await yieldUntil { vm.titleSuggestionApplied }++        #expect(vm.titleSuggestionApplied)+        #expect(!vm.urlSuggestionApplied)+        #expect(vm.suggestionReady)+    }++    @Test("A result arriving after the window is held and only marks the action ready (Req 5.9)")+    func lateResultOutsideWindowIsNotApplied() async throws {+        let delivered = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(delivers: delivered)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        vm.suggestionAutoApplyWindow = .zero++        await vm.load()+        await yieldUntil { vm.suggestionReady }++        #expect(!vm.titleSuggestionApplied)+        #expect(vm.suggestionReady)+        #expect(vm.titleRoles == [.work, .work, .work])+    }++    @Test("The request applies a held suggestion to a dismissed side and clears the dismissal")+    func requestAppliesToDismissedSide() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(held: held)+        coordinator.dismiss(hostname: Self.hostname, side: .title)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        #expect(!vm.titleSuggestionApplied)++        await vm.requestSuggestion()++        #expect(vm.titleSuggestionApplied)+        #expect(!coordinator.isDismissed(hostname: Self.hostname, side: .title))+        #expect(!vm.suggestionBusy)+        #expect(vm.suggestionUnavailableNotice == nil)+    }++    @Test("A request that settles with nothing says so and changes neither side (Req 6.4)")+    func requestWithoutSuggestionShowsTheNotice() async throws {+        let (coordinator, _) = await makeCoordinator()+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        // The editor's own on-open attempt has to settle before the action is+        // triggerable at all (Req 6.3).+        await yieldUntil { !vm.suggestionBusy }++        await vm.requestSuggestion()++        #expect(vm.suggestionUnavailableNotice != nil)+        #expect(!vm.titleSuggestionApplied)+        #expect(!vm.urlSuggestionApplied)+        #expect(!vm.suggestionBusy)+    }++    @Test("Applying a suggestion regenerates the preview for the whole hostname (Req 1.5)")+    func applyingRegeneratesThePreview() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, mock) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)++        await vm.load()+        try await Task.sleep(for: .milliseconds(30))++        let request = try #require(mock.lastProjectComposedRequest)+        #expect(request.titleDefinition == held.title?.definition)+        #expect(vm.state == .previewReady)+    }++    @Test("The Suggest action is offered only while the model is available and the Site is not articles")+    func suggestActionVisibility() async throws {+        let (available, _) = await makeCoordinator()+        let (shown, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: available)+        await shown.load()+        #expect(shown.offersSuggestAction)++        let (away, _) = await makeCoordinator(availability: .unavailable(reason: "test"))+        let (hidden, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: away)+        await hidden.load()+        #expect(!hidden.offersSuggestAction)++        let (none, _) = makeSUT(entry: Self.suggestionEntry())+        await none.load()+        #expect(!none.offersSuggestAction)+    }++    @Test("Saving a rule that differs from the suggestion dismisses that side (Req 2.7)")+    func divergentCommitDismisses() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        // Edit away from the suggestion, then save what the reader authored.+        vm.cycleTitleRole(at: 0)+        try await Task.sleep(for: .milliseconds(30))+        await vm.confirm()++        #expect(vm.state == .committed)+        #expect(coordinator.isDismissed(hostname: Self.hostname, side: .title))+    }++    @Test("Saving the suggestion itself dismisses nothing (Req 2.6)")+    func identicalCommitDoesNotDismiss() async throws {+        let held = Self.suggestion(title: try Self.suggestedTitle())+        let (coordinator, _) = await makeCoordinator(held: held)+        let (vm, _) = makeSUT(entry: Self.suggestionEntry(), suggestions: coordinator)+        await vm.load()+        try await Task.sleep(for: .milliseconds(30))+        await vm.confirm()++        #expect(vm.state == .committed)+        #expect(!coordinator.isDismissed(hostname: Self.hostname, side: .title))+    } }
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Modified +51 / -5
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex 55abafe..6a3b23c 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -182,6 +182,24 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {         return try sitesResult.get()     } +    // MARK: - Rule suggestion candidates++    var ruleSuggestionCandidatesCallCount = 0+    var ruleSuggestionCandidatesResult: Result<[RuleSuggestionCandidate], Error> = .success([])+    /// What the last call asked for: nil is the sweep's whole-store read, a set+    /// is `reconcile`'s tracked hostnames. Doubly optional so a test can tell+    /// "asked for everything" from "was never called".+    var lastRuleSuggestionCandidateHostnames: Set<String>??++    func ruleSuggestionCandidates(hostnames: Set<String>?) async throws -> [RuleSuggestionCandidate] {+        ruleSuggestionCandidatesCallCount += 1+        callLog.append("ruleSuggestionCandidates")+        lastRuleSuggestionCandidateHostnames = hostnames+        let rows = try ruleSuggestionCandidatesResult.get()+        guard let hostnames else { return rows }+        return rows.filter { hostnames.contains($0.hostname) }+    }+     // MARK: - Work types      var workTypesCallCount = 0@@ -479,8 +497,32 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     // MARK: - Composed teaching and recalculation stubs      var projectComposedTeachingResult: Result<ComposedTeachingContract, Error> = .failure(MockError.notConfigured)-    var projectComposedTeachingCallCount = 0-    var lastProjectComposedRequest: ComposedTeachingRequest?+    /// The projection's three records — the count, the last request and the+    /// whole log — all under one lock, unlike the rest of this class: the+    /// composed generation tests deliberately overlap two projections, and+    /// unsynchronised writes from two threads tear (an array append reallocates+    /// under itself, and a count loses an increment).+    var projectComposedTeachingCallCount: Int {+        composedRequestLock.withLock { storedComposedTeachingCallCount }+    }+    var lastProjectComposedRequest: ComposedTeachingRequest? {+        composedRequestLock.withLock { storedLastComposedRequest }+    }+    /// Every request the projection was given, in order. One suggestion attempt+    /// makes a basis call and up to three verification calls, and which sets it+    /// tried is only visible here (design steps 1 and 6).+    var projectComposedRequests: [ComposedTeachingRequest] {+        composedRequestLock.withLock { storedComposedRequests }+    }+    private let composedRequestLock = NSLock()+    private var storedComposedRequests: [ComposedTeachingRequest] = []+    private var storedComposedTeachingCallCount = 0+    private var storedLastComposedRequest: ComposedTeachingRequest?+    /// Answers per request rather than with one preset contract, for the same+    /// reason: a test of the verification ladder has to pass one candidate set+    /// and fail another. Takes precedence over `projectComposedTeachingResult`.+    var projectComposedTeachingHandler:+        (@Sendable (String, ComposedTeachingRequest) throws -> ComposedTeachingContract)?      var commitComposedTeachingResult: Result<ComposedTeachingCommitOutcome, Error> = .failure(MockError.notConfigured)     var commitComposedTeachingCallCount = 0@@ -500,12 +542,16 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     var composedProjectionDelay: Duration?      func projectComposedTeaching(hostname: String, request: ComposedTeachingRequest) async throws -> ComposedTeachingContract {-        projectComposedTeachingCallCount += 1-        lastProjectComposedRequest = request+        composedRequestLock.withLock {+            storedComposedTeachingCallCount += 1+            storedLastComposedRequest = request+            storedComposedRequests.append(request)+        }         if let delay = composedProjectionDelay { try await Task.sleep(for: delay) }         // Mirror the real repository: the returned contract carries the request         // that produced it, so acknowledgment state round-trips into the commit.-        let base = try projectComposedTeachingResult.get()+        let base = try projectComposedTeachingHandler.map { try $0(hostname, request) }+            ?? projectComposedTeachingResult.get()         return ComposedTeachingContract(basis: base.basis, request: request, outcome: base.outcome)     } 
Asterism/AsterismTests/RuleSuggesterTests.swift Added +390 / -0
diff --git a/Asterism/AsterismTests/RuleSuggesterTests.swift b/Asterism/AsterismTests/RuleSuggesterTests.swiftnew file mode 100644index 0000000..866c988--- /dev/null+++ b/Asterism/AsterismTests/RuleSuggesterTests.swift@@ -0,0 +1,390 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import Testing+@testable import Asterism++/// Tests for `RuleSuggester.attempt(hostname:)` — the whole attempt pipeline+/// against a mock library and the scripted model stub. No model is involved,+/// and none of these run on the main actor: the suggester is an actor precisely+/// so the model call and the projections stay off it (Req 5.1).+@Suite("RuleSuggester")+struct RuleSuggesterTests {++    // MARK: - Fixtures++    private static let hostname = "example.com"++    /// Chapter `index` of the one story on the hostname. A higher index is+    /// newer, so the highest is the anchor.+    private static func basisEntry(_ index: Int) -> ComposedEntryBasis {+        ComposedEntryBasis(+            id: UUID(), captureTitle: "Some Story - Chapter \(index)",+            rawURLString: "https://example.com/series/some-story/chapter-\(index)",+            hostname: hostname, firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(index)),+            chapterTitle: nil, chapterTitleProvenance: .none, workID: nil,+            workAssignmentProvenance: .none, intentionallyUnattached: false)+    }++    private static func basis(+        entries: Int = 3,+        currentTitleRule: ComposedTitleRuleBasis? = nil,+        currentURLRule: ComposedURLRuleBasis? = nil+    ) -> ComposedTeachingBasis {+        ComposedTeachingBasis(+            siteMode: currentTitleRule == nil ? .untaught : .taught, hostname: hostname,+            entries: (1...max(entries, 1)).map(basisEntry), works: [],+            currentTitleRule: currentTitleRule, currentURLRule: currentURLRule)+    }++    private static func outcome(+        entryCount: Int = 3, titleFailure: PatternApplicationError? = nil,+        urlFailure: URLRuleApplicationError? = nil, workName: String? = "Some Story",+        requiresAck: Bool = false+    ) -> ComposedTeachingOutcome {+        let entries = (0..<entryCount).map { _ in+            ComposedEntryProjection(+                entryID: UUID(), previousWorkID: nil, previousChapterTitle: nil,+                workName: workName, workNameSource: .parsed,+                projectedChapterTitle: nil, projectedChapterSequence: nil,+                projectedIdentityBasis: .conservative, projectedKeyVersion: 1,+                projectedIdentityKey: "key", assignment: .noChange, projectedWorkID: nil,+                chapterSettled: true, actionableAfter: false,+                titleFailure: titleFailure, urlFailure: urlFailure)+        }+        return ComposedTeachingOutcome(+            titleVersion: .available(1), urlVersion: nil, entries: entries, works: [],+            issues: [], prospectiveWorks: [], requiresUnsettledAcknowledgment: requiresAck)+    }++    private static func contract(+        basis: ComposedTeachingBasis, outcome: ComposedTeachingOutcome+    ) -> ComposedTeachingContract {+        ComposedTeachingContract(+            basis: basis, request: ComposedTeachingRequest(titleDefinition: .wholeTitle),+            outcome: outcome)+    }++    /// The basis read, told apart from the verification calls by its content:+    /// the whole-title, no-URL request the editor's `load()` also makes.+    private static func isBasisRequest(_ request: ComposedTeachingRequest) -> Bool {+        request.urlDefinition == nil && request.titleDefinition == .wholeTitle+            && request.trimPrefix == nil && request.trimSuffix == nil+    }++    /// The proposal for the three-chapter fixture: both sides present,+    /// everything copied verbatim from the anchor.+    private static let goodProposal = RuleProposal(+        workName: "Some Story", chapterText: "Chapter 3",+        urlWorkIdentity: "some-story", urlSequenceText: "chapter-3")++    private func makeSUT(+        basis: ComposedTeachingBasis? = nil,+        basisFailure: (any Error & Sendable)? = nil,+        proposal: RuleProposal? = goodProposal,+        error: (any Error & Sendable)? = nil,+        results: [StubRuleSuggestionModelClient.ScriptedResult] = [],+        delay: Duration? = nil,+        timeout: Duration = .seconds(30),+        verification: (@Sendable (ComposedTeachingRequest) -> ComposedTeachingOutcome)? = nil+    ) -> (RuleSuggester, MockLibraryProvider, StubRuleSuggestionModelClient) {+        let mock = MockLibraryProvider()+        let resolved = basis ?? Self.basis()+        let verify = verification ?? { _ in Self.outcome() }+        mock.projectComposedTeachingHandler = { _, request in+            if let basisFailure, Self.isBasisRequest(request) { throw basisFailure }+            return Self.contract(+                basis: resolved,+                outcome: Self.isBasisRequest(request) ? Self.outcome() : verify(request))+        }+        let stub = StubRuleSuggestionModelClient(+            proposal: proposal, error: error, delay: delay, results: results)+        return (RuleSuggester(library: mock, model: stub, timeout: timeout), mock, stub)+    }++    // MARK: - The happy path++    @Test("Both sides verify together and are offered together")+    func happyPathBothSides() async throws {+        let (suggester, mock, stub) = makeSUT()+        let result = try await suggester.attempt(hostname: Self.hostname)+        let suggestion = try #require(result.suggestion)++        #expect(suggestion.hostname == Self.hostname)+        guard case .segment = try #require(suggestion.title).definition else {+            Issue.record("expected a segment title rule")+            return+        }+        guard case .workAndSequence = try #require(suggestion.url) else {+            Issue.record("expected a two-slot URL rule")+            return+        }+        // One basis read plus one verification: the pair passed first time.+        #expect(mock.projectComposedRequests.count == 2)+        #expect(stub.recorder.callCount == 1)+        #expect(result.modelPhase > .zero)+    }++    @Test("The model is shown the newest capture as the anchor and older ones as context")+    func corpusIsAnchoredOnTheNewestCapture() async throws {+        let (suggester, _, stub) = makeSUT()+        _ = try await suggester.attempt(hostname: Self.hostname)+        let corpus = try #require(stub.recorder.recordedCorpora.first)+        #expect(corpus.anchor.title == "Some Story - Chapter 3")+        #expect(corpus.context.map(\.title) == [+            "Some Story - Chapter 2", "Some Story - Chapter 1",+        ])+    }++    // MARK: - The verification ladder (Req 3.4, Q27, Q48)++    @Test("A pair that fails together is reduced to the title side beside the stored URL rule")+    func pairFailureFallsBackToTitleOnly() async throws {+        let stored = URLRuleDefinition.sequence(locator: .query(name: ExactScalarString("ch")))+        let taughtBasis = Self.basis(+            currentURLRule: ComposedURLRuleBasis(+                id: UUID(), version: 1, origin: .readerTaught, definition: stored))+        let (suggester, mock, _) = makeSUT(basis: taughtBasis) { request in+            // Only the pair carries both candidates; the single-side sets carry+            // one candidate and one stored rule.+            guard case .workAndSequence = request.urlDefinition,+                  case .segment = request.titleDefinition else { return Self.outcome() }+            return Self.outcome(titleFailure: .blankResult(field: "work"))+        }++        let suggestion = try #require(try await suggester.attempt(hostname: Self.hostname).suggestion)+        #expect(suggestion.title != nil)+        #expect(suggestion.url == nil)+        // Basis, the failed pair, then the title alone beside the stored rule.+        #expect(mock.projectComposedRequests.count == 3)+        #expect(mock.projectComposedRequests[2].urlDefinition == stored)+    }++    @Test("A URL-only suggestion is projected with the Site's stored title rule")+    func urlOnlyUsesTheStoredTitleRule() async throws {+        let storedDefinition = PatternDefinition.chapterlessSegment(+            work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: [])+        let taughtBasis = Self.basis(+            currentTitleRule: ComposedTitleRuleBasis(+                id: UUID(), version: 1, definition: storedDefinition,+                trimPrefix: nil, trimSuffix: nil))+        // The Work name is not in the anchor title, so there is no title side.+        let proposal = RuleProposal(+            workName: "Absent From The Title", chapterText: "Chapter 3",+            urlWorkIdentity: "some-story", urlSequenceText: "chapter-3")+        let (suggester, mock, _) = makeSUT(basis: taughtBasis, proposal: proposal)++        let suggestion = try #require(try await suggester.attempt(hostname: Self.hostname).suggestion)+        #expect(suggestion.title == nil)+        #expect(suggestion.url != nil)+        #expect(mock.projectComposedRequests.count == 2)+        #expect(mock.projectComposedRequests[1].titleDefinition == storedDefinition)+    }++    @Test("A URL-only suggestion on an untaught Site is projected with the whole-title default")+    func urlOnlyUsesWholeTitleWhenUntaught() async throws {+        let proposal = RuleProposal(+            workName: "Absent From The Title", chapterText: "Chapter 3",+            urlWorkIdentity: "some-story", urlSequenceText: "chapter-3")+        let (suggester, mock, _) = makeSUT(proposal: proposal)++        let suggestion = try #require(try await suggester.attempt(hostname: Self.hostname).suggestion)+        #expect(suggestion.url != nil)+        #expect(mock.projectComposedRequests[1].titleDefinition == .wholeTitle)+    }++    @Test("A title rule equal to the untaught default is never projected on its own")+    func defaultTitleAloneIsNotOffered() async throws {+        // The chapter text sits inside the Work text, so the optional span is+        // dropped (Req 3.1) and the title side collapses to the whole-title+        // default — which may ride along in the pair but must never be a set of+        // its own.+        let proposal = RuleProposal(+            workName: "Some Story - Chapter 3", chapterText: "Chapter 3",+            urlWorkIdentity: "some-story", urlSequenceText: "chapter-3")+        let (suggester, mock, _) = makeSUT(proposal: proposal) { _ in+            Self.outcome(titleFailure: .blankResult(field: "work"))+        }++        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+        // Basis, the pair, the URL alone. A fourth call would be the title-only+        // set, which carries nothing but the default and is skipped.+        #expect(mock.projectComposedRequests.count == 3)+        #expect(mock.projectComposedRequests.dropFirst().allSatisfy { $0.urlDefinition != nil })+    }++    @Test("An entry the title rule fails on fails the whole set")+    func titleFailureFailsTheSet() async throws {+        let (suggester, _, _) = makeSUT { _ in+            Self.outcome(titleFailure: .blankResult(field: "work"))+        }+        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+    }++    @Test("An entry the URL rule fails on fails the whole set")+    func urlFailureFailsTheSet() async throws {+        let (suggester, _, _) = makeSUT { _ in+            Self.outcome(urlFailure: .ambiguousSeparator(count: 0))+        }+        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+    }++    @Test("An entry with no Work name fails the whole set")+    func missingWorkNameFailsTheSet() async throws {+        let (suggester, _, _) = makeSUT { _ in Self.outcome(workName: nil) }+        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+    }++    @Test("A set that would require the unsettled-chapters acknowledgment fails")+    func unsettledAcknowledgmentFailsTheSet() async throws {+        let (suggester, _, _) = makeSUT { _ in Self.outcome(requiresAck: true) }+        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+    }++    // MARK: - Locating and the short-circuit (Reqs 3.1, Q44)++    @Test("Neither a chapter nor a URL sequence settles the attempt before any verification")+    func noChapterAndNoSequenceShortCircuits() async throws {+        let proposal = RuleProposal(+            workName: "Some Story", chapterText: "",+            urlWorkIdentity: "some-story", urlSequenceText: "")+        let (suggester, mock, stub) = makeSUT(proposal: proposal)++        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+        // The model ran; nothing was projected beyond the basis read.+        #expect(stub.recorder.callCount == 1)+        #expect(mock.projectComposedRequests.count == 1)+    }++    @Test("Chapter text sitting inside the Work text costs the chapter, not the suggestion")+    func chapterInsideTheWorkNameDropsOnlyTheChapter() async throws {+        // Q49: an optional span overlapping its required one is dropped, rather+        // than failing the whole proposal. The URL sequence is what keeps the+        // chapters settled once the title has none.+        let proposal = RuleProposal(+            workName: "Some Story - Chapter 3", chapterText: "Chapter 3",+            urlWorkIdentity: "some-story", urlSequenceText: "chapter-3")+        let (suggester, mock, _) = makeSUT(proposal: proposal)++        let suggestion = try #require(try await suggester.attempt(hostname: Self.hostname).suggestion)++        #expect(suggestion.url != nil)+        // The title rule that survives names no chapter, so the projected pair+        // carries a rule over the whole title rather than a two-role selection.+        let paired = try #require(mock.projectComposedRequests.dropFirst().first)+        #expect(paired.titleDefinition == .wholeTitle)+        // Basis and the pair: the ladder never had to fall back.+        #expect(mock.projectComposedRequests.count == 2)+    }++    @Test("URL sequence text sitting inside the identity costs the sequence, not the URL side")+    func sequenceInsideTheIdentityDropsOnlyTheSequence() async throws {+        // "chapter" occurs once, inside the "chapter-3" the model also named as+        // the identity — one component, two overlapping spans.+        let proposal = RuleProposal(+            workName: "Some Story", chapterText: "Chapter 3",+            urlWorkIdentity: "chapter-3", urlSequenceText: "chapter")+        let (suggester, _, _) = makeSUT(proposal: proposal)++        let suggestion = try #require(try await suggester.attempt(hostname: Self.hostname).suggestion)+        let url = try #require(suggestion.url)++        #expect(url.suppliesIdentity)+        // The sequence span went; the identity stands on its own, and the title+        // side is what settles the chapters.+        #expect(!url.suppliesSequence)+        #expect(suggestion.title != nil)+    }++    @Test("Text the model copied out of the host is located in no component")+    func identityInTheHostYieldsNoURLSide() async throws {+        let proposal = RuleProposal(+            workName: "Some Story", chapterText: "Chapter 3",+            urlWorkIdentity: "example.com", urlSequenceText: "chapter-3")+        let (suggester, mock, _) = makeSUT(proposal: proposal)++        let suggestion = try #require(try await suggester.attempt(hostname: Self.hostname).suggestion)+        #expect(suggestion.url == nil)+        #expect(suggestion.title != nil)+        #expect(mock.projectComposedRequests[1].urlDefinition == nil)+    }++    // MARK: - Model failures (Reqs 3.7, 4.2)++    @Test("A context-window overflow halves the corpus and retries within the attempt")+    func contextWindowOverflowHalvesAndRetries() async throws {+        // Five captures, so the anchor is chapter 5 and the proposal quotes it.+        let anchored = RuleProposal(+            workName: "Some Story", chapterText: "Chapter 5",+            urlWorkIdentity: "some-story", urlSequenceText: "chapter-5")+        let (suggester, _, stub) = makeSUT(+            basis: Self.basis(entries: 5), proposal: nil,+            results: [+                .failure(StubRuleSuggestionModelClientError.contextWindowOverflow),+                .success(anchored),+            ])++        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion != nil)+        #expect(stub.recorder.callCount == 2)+        // Five captures cap at the anchor plus four context entries; the retry+        // drops half of them, oldest first.+        #expect(stub.recorder.recordedCorpora.map(\.context.count) == [4, 2])+    }++    @Test("Any other model failure settles the attempt with nothing")+    func otherModelErrorYieldsNothing() async throws {+        let (suggester, mock, _) = makeSUT(+            proposal: nil, error: StubRuleSuggestionModelClientError.noCannedProposal)+        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+        #expect(mock.projectComposedRequests.count == 1)+    }++    @Test("A basis read that throws — an articles Site — settles the attempt with nothing")+    func articlesBasisThrowYieldsNothing() async throws {+        let (suggester, _, stub) = makeSUT(+            basisFailure: ComposedTeachingProjectionError.invalidTitleDefinition(+                reason: "articles"))++        let result = try await suggester.attempt(hostname: Self.hostname)+        #expect(result.suggestion == nil)+        #expect(result.modelPhase == .zero)+        #expect(stub.recorder.callCount == 0)+    }++    @Test("A hostname with no captures settles the attempt with nothing")+    func noCapturesYieldsNothing() async throws {+        let empty = ComposedTeachingBasis(+            siteMode: .untaught, hostname: Self.hostname, entries: [], works: [],+            currentTitleRule: nil, currentURLRule: nil)+        let (suggester, _, stub) = makeSUT(basis: empty)+        #expect(try await suggester.attempt(hostname: Self.hostname).suggestion == nil)+        #expect(stub.recorder.callCount == 0)+    }++    // MARK: - Timeout and cancellation (Reqs 5.4, 5.10, Q45)++    @Test("An attempt that outlives its bound throws AttemptTimeout carrying what it spent")+    func timeoutThrowsAttemptTimeout() async throws {+        let (suggester, _, _) = makeSUT(delay: .seconds(30), timeout: .milliseconds(50))+        do {+            _ = try await suggester.attempt(hostname: Self.hostname)+            Issue.record("expected the attempt to time out")+        } catch let timeout as AttemptTimeout {+            #expect(timeout.modelPhase >= .milliseconds(50))+        }+    }++    @Test("A system cancellation propagates rather than settling as no suggestion")+    func cancellationPropagates() async throws {+        let (suggester, _, stub) = makeSUT(delay: .seconds(30))+        let task = Task { try await suggester.attempt(hostname: Self.hostname) }+        while stub.recorder.callCount == 0 { await Task.yield() }+        task.cancel()+        do {+            _ = try await task.value+            Issue.record("expected the attempt to be cancelled")+        } catch is CancellationError {+            // The system's own cancellation, distinct from a timeout (Q45).+        }+    }+}
Asterism/AsterismTests/RuleSuggestionAssemblerTests.swift Added +217 / -0
diff --git a/Asterism/AsterismTests/RuleSuggestionAssemblerTests.swift b/Asterism/AsterismTests/RuleSuggestionAssemblerTests.swiftnew file mode 100644index 0000000..d5cc143--- /dev/null+++ b/Asterism/AsterismTests/RuleSuggestionAssemblerTests.swift@@ -0,0 +1,217 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import Testing+@testable import Asterism++/// Tests for `RuleSuggestionAssembler` — design step 5, the span→rule seam.+///+/// The suite is deliberately **not** `@MainActor`: the assembler runs on the+/// `RuleSuggester` actor, and the editor helpers it drives are only usable+/// there because task 7 made them `nonisolated`. A main-actor suite would pass+/// while the suggester still failed to compile.+@Suite("RuleSuggestionAssembler")+struct RuleSuggestionAssemblerTests {++    // MARK: - Helpers++    private static func components(_ rawURL: String) throws -> RawURLLexicalComponents {+        try RawURLRuleParser.parse(ExactScalarString(rawURL))+    }++    /// The character range of `text` in `source`, as the locator would produce.+    private static func span(_ text: String, in source: String) throws -> Range<Int> {+        try #require(ProposalLocator.locate(text, in: source))+    }++    // MARK: - Title assembly++    @Test("The whole title marked as the Work yields the whole-title rule with no trims")+    func wholeTitleWork() throws {+        let title = "Some Story"+        let rule = try #require(RuleSuggestionAssembler.titleRule(+            anchorTitle: title, workSpan: Self.span("Some Story", in: title), chapterSpan: nil))+        #expect(rule.definition == .wholeTitle)+        #expect(rule.trimPrefix == nil)+        #expect(rule.trimSuffix == nil)+    }++    @Test("Work and chapter on whole segments yield the segment form")+    func workAndChapterSegments() throws {+        let title = "Some Story - Chapter 3"+        let rule = try #require(RuleSuggestionAssembler.titleRule(+            anchorTitle: title,+            workSpan: Self.span("Some Story", in: title),+            chapterSpan: Self.span("Chapter 3", in: title)))+        guard case .segment = rule.definition else {+            Issue.record("expected a segment form, got \(rule.definition)")+            return+        }+    }++    @Test("A chapter segment before the work segment still yields the segment form")+    func chapterBeforeWork() throws {+        let title = "Chapter 3 - Some Story"+        let rule = try #require(RuleSuggestionAssembler.titleRule(+            anchorTitle: title,+            workSpan: Self.span("Some Story", in: title),+            chapterSpan: Self.span("Chapter 3", in: title)))+        guard case .segment = rule.definition else {+            Issue.record("expected a segment form, got \(rule.definition)")+            return+        }+    }++    @Test("Sub-segment spans yield a phrase rule")+    func subSegmentPhrase() throws {+        let title = "Prologue of Story #12"+        let rule = try #require(RuleSuggestionAssembler.titleRule(+            anchorTitle: title,+            workSpan: Self.span("Story", in: title),+            chapterSpan: Self.span("12", in: title)))+        guard case .phrase = rule.definition else {+            Issue.record("expected a phrase form, got \(rule.definition)")+            return+        }+    }++    @Test("A selection whose separator is whitespace-only is unauthorable and yields nothing")+    func whitespaceSeparatorIsUnauthorable() throws {+        // Parts are maximal alphanumeric runs, so the separator between "Story"+        // and "12" is a bare space — which `PhrasePatternDeriver` refuses. The+        // chip cycle keeps this selection unreachable for a reader; a model+        // proposal can still land on it, so the assembler has to refuse it too.+        let title = "Some Story 12"+        #expect(RuleSuggestionAssembler.titleRule(+            anchorTitle: title,+            workSpan: try Self.span("Some Story", in: title),+            chapterSpan: try Self.span("12", in: title)) == nil)+    }++    @Test("An out-of-bounds work span yields nothing")+    func outOfBoundsTitleSpan() {+        #expect(RuleSuggestionAssembler.titleRule(+            anchorTitle: "Some Story", workSpan: 50..<60, chapterSpan: nil) == nil)+    }++    // MARK: - URL assembly++    @Test("An identity in one path component yields a Work-identity rule")+    func identityInOnePathComponent() throws {+        let raw = "https://example.com/series/some-story/chapter-3"+        let parsed = try Self.components(raw)+        let definition = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 0..<10), sequence: nil))+        guard case .work(let locator) = definition else {+            Issue.record("expected a Work-identity rule, got \(definition)")+            return+        }+        #expect(locator == .pathBracketed(+            left: .literal(ExactScalarString("series")), right: .unanchored))+    }++    @Test("Identity and sequence in different components yield a two-slot rule")+    func identityAndSequenceInDifferentComponents() throws {+        let raw = "https://example.com/series/some-story/chapter-3"+        let parsed = try Self.components(raw)+        let definition = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 0..<10), sequence: (.path(2), 8..<9)))+        guard case .workAndSequence(let work, let sequence) = definition else {+            Issue.record("expected a two-slot rule, got \(definition)")+            return+        }+        #expect(work.locator == .pathBracketed(+            left: .literal(ExactScalarString("series")), right: .unanchored))+        #expect(sequence.locator == .pathBracketed(left: .unanchored, right: .end))+    }++    @Test("An identity span in its own component is widened to the whole component")+    func identitySpanIsWidenedWhenTheSequenceIsElsewhere() throws {+        // The spans are component-relative and only ever describe a *split*.+        // With the sequence in another component there is nothing to split, so+        // the identity slot takes its component whole — exactly as a chip tap+        // does — and the partial span is discarded rather than narrowing the+        // rule to something the editor could not have authored.+        let raw = "https://example.com/series/some-story/chapter-3"+        let parsed = try Self.components(raw)+        let partial = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 0..<4), sequence: (.path(2), 8..<9)))+        let whole = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 0..<10), sequence: (.path(2), 8..<9)))++        #expect(partial == whole)+    }++    @Test("A sequence span before the identity span yields the sequence-first order")+    func sequenceBeforeIdentityInOneComponent() throws {+        // The model is not asked for an order and Req 3.1 does not impose one+        // (Q49); the deriver reads the order off the two spans.+        let raw = "https://example.com/series/3-somestory"+        let parsed = try Self.components(raw)+        let definition = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 2..<11), sequence: (.path(1), 0..<1)))+        guard case .combined(_, let template) = definition else {+            Issue.record("expected a combined rule, got \(definition)")+            return+        }+        #expect(template.order == .sequenceThenWork)+        #expect(template.separator.value == "-")+    }++    @Test("Identity and sequence in the same component yield a combined template")+    func identityAndSequenceInOneComponent() throws {+        let raw = "https://example.com/series/somestory-3"+        let parsed = try Self.components(raw)+        let definition = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 0..<9), sequence: (.path(1), 10..<11)))+        guard case .combined(let locator, let template) = definition else {+            Issue.record("expected a combined rule, got \(definition)")+            return+        }+        #expect(locator == .pathBracketed(+            left: .literal(ExactScalarString("series")), right: .end))+        // The split's separator, not a guess: the deriver read it off the+        // component between the two spans.+        #expect(template.separator.value == "-")+    }++    @Test("A sequence span overlapping the identity span in one component is dropped")+    func overlappingSplitFallsBackToTheWholeComponent() throws {+        // The suggester drops an overlapping optional span before it reaches+        // here; this pins what the assembler does if one arrives anyway — the+        // deriver refuses, and a refused split is no rule rather than a wrong+        // one.+        let raw = "https://example.com/series/somestory-3"+        let parsed = try Self.components(raw)+        #expect(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(1), 0..<11), sequence: (.path(1), 0..<11)) == nil)+    }++    @Test("A selection that is not a component of this URL yields nothing")+    func selectionOutsideTheURL() throws {+        // Text the model copied out of the *host* is located in no path+        // component and no query value, so the suggester forms no selection for+        // it at all. An index past the path is the same shape, and is what the+        // assembler must refuse.+        let parsed = try Self.components("https://example.com/series/some-story")+        #expect(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(9), 0..<4), sequence: nil) == nil)+    }++    @Test("A component no anchoring can single out yields nothing")+    func unauthorableComponent() throws {+        // Both neighbours are blank, so the only offered pair is+        // unanchored/unanchored — which the representation always refuses.+        let parsed = try Self.components("https://example.com/a//x//b")+        #expect(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.path(2), 0..<1), sequence: nil) == nil)+    }++    @Test("A query value carries the identity as a query locator")+    func identityInAQueryValue() throws {+        let parsed = try Self.components("https://example.com/read.php?story=somestory&ch=3")+        let definition = try #require(RuleSuggestionAssembler.urlRule(+            components: parsed, identity: (.query(0), 0..<9), sequence: nil))+        #expect(definition == .work(locator: .query(name: ExactScalarString("story"))))+    }+}
Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift Added +677 / -0
diff --git a/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift b/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swiftnew file mode 100644index 0000000..20e9ccb--- /dev/null+++ b/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift@@ -0,0 +1,677 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import Testing+@testable import Asterism++/// Tests for `RuleSuggestionCoordinator`: the sweep, invalidation, delivery and+/// the gates, with the attempt itself stubbed out. The ledger's own transitions+/// are covered host-side in `RuleSuggestionLedgerTests`; what is pinned here is+/// what the coordinator does *around* them — the `Task`, the clock, the library+/// read, and the callers waiting on a result.+@Suite("RuleSuggestionCoordinator")+@MainActor+struct RuleSuggestionCoordinatorTests {++    // MARK: - Doubles++    /// The attempt seam. Records what it was asked and how many attempts were+    /// running at once, so "at most one computation at a time" (Req 5.11) is an+    /// assertion rather than an inference.+    final class StubSuggester: RuleSuggesting, @unchecked Sendable {+        private let lock = NSLock()+        private var _calls: [String] = []+        private var _concurrent = 0+        private var _peakConcurrent = 0+        private var _isGated = false+        private var _gateWaiters: [CheckedContinuation<Void, Never>] = []+        private var _arrivalWaiters: [(count: Int, waiter: CheckedContinuation<Void, Never>)] = []++        /// Answers per hostname; a hostname with no entry settles with nothing.+        nonisolated(unsafe) var suggestions: [String: RuleSuggestion] = [:]+        nonisolated(unsafe) var delay: Duration?+        nonisolated(unsafe) var modelPhase: Duration = .milliseconds(100)+        nonisolated(unsafe) var failure: (any Error)?++        var calls: [String] { lock.withLock { _calls } }+        var peakConcurrent: Int { lock.withLock { _peakConcurrent } }++        /// Holds every attempt at a gate the test opens by hand, so a test can+        /// keep an attempt in flight without sleeping for it.+        var isGated: Bool {+            get { lock.withLock { _isGated } }+            set { lock.withLock { _isGated = newValue } }+        }++        /// Releases the gate and lets later attempts through it.+        func openGate() {+            let waiting = lock.withLock { () -> [CheckedContinuation<Void, Never>] in+                _isGated = false+                defer { _gateWaiters = [] }+                return _gateWaiters+            }+            for waiter in waiting { waiter.resume() }+        }++        /// Suspends until `count` attempts have started. The deterministic+        /// alternative to a sleep: the caller knows the attempt is in flight+        /// because the attempt said so.+        func waitForAttempts(_ count: Int) async {+            await withCheckedContinuation { continuation in+                let alreadyThere = lock.withLock { () -> Bool in+                    guard _calls.count < count else { return true }+                    _arrivalWaiters.append((count, continuation))+                    return false+                }+                if alreadyThere { continuation.resume() }+            }+        }++        func attempt(+            hostname: String+        ) async throws -> (suggestion: RuleSuggestion?, modelPhase: Duration) {+            let arrived = lock.withLock { () -> [CheckedContinuation<Void, Never>] in+                _calls.append(hostname)+                _concurrent += 1+                _peakConcurrent = max(_peakConcurrent, _concurrent)+                let ready = _arrivalWaiters.filter { $0.count <= _calls.count }+                _arrivalWaiters.removeAll { $0.count <= _calls.count }+                return ready.map(\.waiter)+            }+            for waiter in arrived { waiter.resume() }+            defer { lock.withLock { _concurrent -= 1 } }++            if isGated {+                await withCheckedContinuation { continuation in+                    let open = lock.withLock { () -> Bool in+                        guard _isGated else { return true }+                        _gateWaiters.append(continuation)+                        return false+                    }+                    if open { continuation.resume() }+                }+                // Deliberately checked only *after* the gate opens: a real+                // attempt does not stop the instant it is cancelled either, and+                // the gap between the two is where several of these tests live.+                try Task.checkCancellation()+            }+            if let delay { try await Task.sleep(for: delay) }+            if let failure { throw failure }+            return (suggestions[hostname], modelPhase)+        }+    }++    /// A model client whose availability can change between activations, which+    /// is the whole point of re-reading it (Q41). The stub in the package is a+    /// value type, so a copy the coordinator holds could never change.+    final class MutableAvailabilityClient: RuleSuggestionModelClient, @unchecked Sendable {+        nonisolated(unsafe) var availabilityResult: ModelAvailability++        init(_ availability: ModelAvailability) { self.availabilityResult = availability }++        func availability() -> ModelAvailability { availabilityResult }++        /// Never reached: the coordinator's tests stub the suggester, which is+        /// the only thing that talks to a model.+        func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal {+            throw StubRuleSuggestionModelClientError.noCannedProposal+        }+    }++    @MainActor+    final class StubEnvironment: SuggestionEnvironment {+        var isActive = true+        var isLowPowerModeEnabled = false+        var thermalState: ProcessInfo.ThermalState = .nominal+        /// A hand-advanced clock: the coordinator reads it when an attempt+        /// starts and again when a cancellation settles, and nothing else.+        var elapsed: Duration = .zero+        private let base = ContinuousClock.now+        var now: ContinuousClock.Instant { base.advanced(by: elapsed) }+    }++    // MARK: - Fixtures++    private static func candidate(+        _ hostname: String, capturedAt: TimeInterval = 0, mode: SiteMode = .untaught,+        titleRuleVersion: Int? = nil, urlRuleVersion: Int? = nil, entryCount: Int = 3+    ) -> RuleSuggestionCandidate {+        RuleSuggestionCandidate(+            hostname: hostname, siteMode: mode, titleRuleVersion: titleRuleVersion,+            urlRuleVersion: urlRuleVersion, entryCount: entryCount,+            latestCaptureAt: Date(timeIntervalSince1970: capturedAt))+    }++    private static func suggestion(_ hostname: String) -> RuleSuggestion {+        RuleSuggestion(+            hostname: hostname, title: TitleRuleSuggestion(definition: .wholeTitle), url: nil)+    }++    private func makeSUT(+        candidates: [RuleSuggestionCandidate] = [],+        availability: ModelAvailability = .available+    ) -> (RuleSuggestionCoordinator, MockLibraryProvider, StubSuggester, StubEnvironment,+          MutableAvailabilityClient) {+        let mock = MockLibraryProvider()+        mock.ruleSuggestionCandidatesResult = .success(candidates)+        let suggester = StubSuggester()+        let environment = StubEnvironment()+        let client = MutableAvailabilityClient(availability)+        let coordinator = RuleSuggestionCoordinator(+            library: mock, model: client, suggester: suggester, environment: environment)+        return (coordinator, mock, suggester, environment, client)+    }++    /// Spins the main actor until `condition` holds, or until the bound is+    /// reached. No wall-clock sleep is involved: it costs nothing when the+    /// condition is already true, and a coordinator that never gets there fails+    /// the assertion that follows rather than hanging the suite.+    private func yieldUntil(_ condition: () -> Bool) async {+        var spins = 0+        while !condition(), spins < 100 {+            await Task.yield()+            spins += 1+        }+    }++    // MARK: - The activation sweep (Req 5.1)++    @Test("The sweep attempts the three most recent auto-eligible hostnames, newest first")+    func sweepRespectsDepthOrderAndEligibility() async {+        let candidates = [+            Self.candidate("old.test", capturedAt: 10),+            Self.candidate("newest.test", capturedAt: 50),+            Self.candidate("middle.test", capturedAt: 30),+            Self.candidate("second.test", capturedAt: 40),+            // Not auto-eligible: articles mode, no captures, both rules stored.+            Self.candidate("articles.test", capturedAt: 60, mode: .articles),+            Self.candidate("empty.test", capturedAt: 70, entryCount: 0),+            Self.candidate(+                "taught.test", capturedAt: 80, mode: .taught,+                titleRuleVersion: 1, urlRuleVersion: 1),+        ]+        let (coordinator, _, suggester, _, _) = makeSUT(candidates: candidates)++        await coordinator.activationSweep()++        #expect(suggester.calls == ["newest.test", "second.test", "middle.test"])+    }++    @Test("A hostname attempted this run is not swept again")+    func sweepSkipsAttemptedHostnames() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test", capturedAt: 10)])++        await coordinator.activationSweep()+        await coordinator.activationSweep()++        #expect(suggester.calls == ["a.test"])+    }++    @Test("Two sweeps overlapping run as one")+    func sweepIsSingleInstance() async {+        let candidates = (1...3).map { Self.candidate("h\($0).test", capturedAt: TimeInterval($0)) }+        let (coordinator, _, suggester, _, _) = makeSUT(candidates: candidates)+        suggester.delay = .milliseconds(20)++        async let first: Void = coordinator.activationSweep()+        async let second: Void = coordinator.activationSweep()+        _ = await (first, second)++        #expect(suggester.calls.count == 3)+        #expect(suggester.peakConcurrent == 1)+    }++    @Test("A sweep stopped mid-attempt does not swallow the next activation's sweep")+    func stoppedSweepDoesNotSwallowTheNextActivation() async {+        let candidates = (1...3).map {+            Self.candidate("h\($0).test", capturedAt: TimeInterval(10 * $0))+        }+        let (coordinator, mock, suggester, _, _) = makeSUT(candidates: candidates)+        suggester.isGated = true++        async let stopped: Void = coordinator.activationSweep()+        await suggester.waitForAttempts(1)+        // Backgrounded: the sweep is stopped, but the attempt it is awaiting+        // does not notice its cancellation until the gate opens.+        coordinator.resignActive()+        // ... and straight back to the foreground, which is the ordinary shape+        // of a share-then-return.+        async let reactivated: Void = coordinator.activationSweep()+        await yieldUntil { mock.ruleSuggestionCandidatesCallCount > 1 }+        suggester.openGate()+        _ = await (stopped, reactivated)++        // The stopped sweep attempted one hostname and was cancelled out of it,+        // leaving it attemptable; the second activation then swept the full+        // depth, rather than running under the first sweep, refusing every+        // candidate and being switched off by it.+        #expect(suggester.calls == ["h3.test", "h3.test", "h2.test", "h1.test"])+        #expect(suggester.peakConcurrent == 1)+    }++    @Test("Resigning active stops the sweep where it stands")+    func resignActiveStopsTheSweepAtTheNextCandidate() async {+        let candidates = (1...3).map {+            Self.candidate("h\($0).test", capturedAt: TimeInterval(10 * $0))+        }+        let (coordinator, _, suggester, _, _) = makeSUT(candidates: candidates)+        suggester.isGated = true++        async let sweep: Void = coordinator.activationSweep()+        await suggester.waitForAttempts(1)+        coordinator.resignActive()+        suggester.openGate()+        await sweep++        // Candidate 2 is never reached: the sweep stops with the foreground+        // (Req 5.3), and the attempt it was awaiting stays unattempted (Q28).+        #expect(suggester.calls == ["h3.test"])+        #expect(coordinator.isAttempted("h3.test") == false)+    }++    @Test("A hostname the reader rejected on both sides is never swept again")+    func sweepSkipsFullyDismissedHostnames() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test", capturedAt: 10)])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        await coordinator.activationSweep()+        #expect(suggester.calls == ["a.test"])++        coordinator.dismiss(hostname: "a.test", side: .title)+        coordinator.dismiss(hostname: "a.test", side: .url)++        // A capture lands. Invalidation drops the hold and reopens the hostname+        // by design, but it keeps the rejection (Q18) — so the sweep must pass+        // it over rather than spend a slot and up to 10 s of budget on a+        // suggestion the reader has already turned down twice.+        mock.ruleSuggestionCandidatesResult = .success(+            [Self.candidate("a.test", capturedAt: 10, entryCount: 4)])+        await coordinator.reconcile()+        #expect(coordinator.isAttempted("a.test") == false)++        await coordinator.activationSweep()+        #expect(suggester.calls == ["a.test"])+    }++    @Test("A spent budget stops the sweep before it reads the library")+    func exhaustedBudgetSkipsTheCandidateRead() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.modelPhase = RuleSuggestionBounds.runTimeBudget+        await coordinator.activationSweep()+        let reads = mock.ruleSuggestionCandidatesCallCount++        await coordinator.activationSweep()++        // Every candidate would be refused for the budget anyway (Q31), and the+        // read that would establish that is 2N SwiftData fetches per activation.+        #expect(mock.ruleSuggestionCandidatesCallCount == reads)+        #expect(suggester.calls == ["a.test"])+    }++    @Test("The sweep starts nothing in Low Power Mode or a serious thermal state")+    func sweepGates() async {+        let (lowPower, _, lowPowerSuggester, lowPowerEnvironment, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        lowPowerEnvironment.isLowPowerModeEnabled = true+        await lowPower.activationSweep()+        #expect(lowPowerSuggester.calls.isEmpty)++        let (hot, _, hotSuggester, hotEnvironment, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        hotEnvironment.thermalState = .serious+        await hot.activationSweep()+        #expect(hotSuggester.calls.isEmpty)+    }++    @Test("Model availability is re-read on every activation")+    func modelAvailabilityIsReReadOnSweep() async {+        let (coordinator, _, suggester, _, client) = makeSUT(+            candidates: [Self.candidate("a.test")],+            availability: .unavailable(reason: "downloading"))+        #expect(coordinator.isModelAvailable == false)++        await coordinator.activationSweep()+        #expect(suggester.calls.isEmpty)++        client.availabilityResult = .available+        await coordinator.activationSweep()+        #expect(coordinator.isModelAvailable)+        #expect(suggester.calls == ["a.test"])+    }++    // MARK: - Invalidation (Req 5.5, Q57)++    @Test("Reconcile asks only about the hostnames the ledger tracks")+    func reconcileAsksAboutTrackedHostnamesOnly() async {+        let (coordinator, mock, _, _, _) = makeSUT(+            candidates: [+                Self.candidate("a.test", capturedAt: 20), Self.candidate("b.test", capturedAt: 10),+            ])+        await coordinator.activationSweep()++        await coordinator.reconcile()+        #expect(mock.lastRuleSuggestionCandidateHostnames == Set(["a.test", "b.test"]))+    }++    @Test("Reconcile does not read the library when nothing is tracked")+    func reconcileIsFreeWhenNothingIsTracked() async {+        let (coordinator, mock, _, _, _) = makeSUT()+        await coordinator.reconcile()+        #expect(mock.ruleSuggestionCandidatesCallCount == 0)+    }++    @Test("A changed candidate row drops the held suggestion and makes the hostname attemptable")+    func reconcileInvalidatesOnFingerprintMismatch() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test", capturedAt: 10)])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        await coordinator.activationSweep()+        #expect(coordinator.held(for: "a.test") != nil)++        // One more capture on the hostname.+        mock.ruleSuggestionCandidatesResult = .success(+            [Self.candidate("a.test", capturedAt: 10, entryCount: 4)])+        await coordinator.reconcile()++        #expect(coordinator.held(for: "a.test") == nil)+        #expect(coordinator.isAttempted("a.test") == false)+        await coordinator.activationSweep()+        #expect(suggester.calls == ["a.test", "a.test"])+    }++    @Test("A memory warning drops held suggestions and reopens their hostnames")+    func memoryWarningClearsTheRun() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        await coordinator.activationSweep()++        coordinator.memoryWarning()++        #expect(coordinator.held(for: "a.test") == nil)+        #expect(coordinator.isAttempted("a.test") == false)+    }++    @Test("A memory warning cancels the attempt in flight and throws its result away")+    func memoryWarningCancelsTheAttemptInFlight() async {+        let (coordinator, _, suggester, environment, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.isGated = true++        async let requested = coordinator.suggestion(for: "a.test", origin: .request)+        await suggester.waitForAttempts(1)+        environment.elapsed = .milliseconds(120)+        coordinator.memoryWarning()+        suggester.openGate()++        // The attempt was computed against state the warning has just dropped,+        // so nothing it returns is held and the hostname stays attemptable+        // (Req 5.6, Q50) — but it is charged for what it ran (Req 5.2).+        #expect(await requested == nil)+        #expect(coordinator.held(for: "a.test") == nil)+        #expect(coordinator.isAttempted("a.test") == false)+        #expect(coordinator.budgetSpent == .milliseconds(120))+    }++    @Test("Reconcile voids the attempt in flight, so its result is discarded")+    func reconcileDiscardsTheInFlightResult() async {+        let (coordinator, mock, suggester, environment, _) = makeSUT(+            candidates: [Self.candidate("a.test", capturedAt: 10)])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.isGated = true++        async let requested = coordinator.suggestion(for: "a.test", origin: .request)+        await suggester.waitForAttempts(1)+        // A capture lands on the hostname while the attempt is running: what it+        // is computing is already about a corpus that no longer exists.+        mock.ruleSuggestionCandidatesResult = .success(+            [Self.candidate("a.test", capturedAt: 10, entryCount: 4)])+        await coordinator.reconcile()+        environment.elapsed = .milliseconds(80)+        suggester.openGate()++        #expect(await requested == nil)+        #expect(coordinator.held(for: "a.test") == nil)+        #expect(coordinator.isAttempted("a.test") == false)+        #expect(coordinator.budgetSpent == .milliseconds(80))+    }++    // MARK: - Delivery (Reqs 5.7, 6.6)++    @Test("A held suggestion is returned without a model call")+    func heldIsReturnedWithoutACall() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        await coordinator.activationSweep()++        let delivered = await coordinator.suggestion(for: "a.test", origin: .open)+        #expect(delivered == Self.suggestion("a.test"))+        #expect(suggester.calls == ["a.test"])+    }++    @Test("An editor open on an already-attempted hostname starts nothing")+    func openIsRefusedWhenAttempted() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        await coordinator.activationSweep()+        #expect(coordinator.isAttempted("a.test"))++        #expect(await coordinator.suggestion(for: "a.test", origin: .open) == nil)+        #expect(suggester.calls == ["a.test"])+    }++    @Test("An open on an already-attempted hostname is refused without reading the library")+    func openRefusedWithoutALibraryRead() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        await coordinator.activationSweep()+        #expect(coordinator.isAttempted("a.test"))+        let reads = mock.ruleSuggestionCandidatesCallCount++        #expect(await coordinator.suggestion(for: "a.test", origin: .open) == nil)++        // Every editor open on such a hostname would otherwise pay the+        // candidate read — a count and a fetch per hostname — only to be told+        // what the ledger already knew.+        #expect(mock.ruleSuggestionCandidatesCallCount == reads)+        #expect(suggester.calls == ["a.test"])+    }++    @Test("An open behind the reader's own request for another hostname is refused for free")+    func openRefusedBehindARequest() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(candidates: [+            Self.candidate("a.test", capturedAt: 20), Self.candidate("b.test", capturedAt: 10),+        ])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.isGated = true++        async let requested = coordinator.suggestion(for: "a.test", origin: .request)+        await suggester.waitForAttempts(1)+        let reads = mock.ruleSuggestionCandidatesCallCount++        // Q55: an open never pre-empts a request, so there is nothing for it to+        // do here — and no library read is needed to establish that.+        #expect(await coordinator.suggestion(for: "b.test", origin: .open) == nil)+        #expect(mock.ruleSuggestionCandidatesCallCount == reads)++        suggester.openGate()+        #expect(await requested == Self.suggestion("a.test"))+        #expect(suggester.calls == ["a.test"])+    }++    @Test("The reader's request starts an attempt even on an attempted hostname")+    func requestStartsWhenAttempted() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        await coordinator.activationSweep()++        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        let delivered = await coordinator.suggestion(for: "a.test", origin: .request)+        #expect(delivered == Self.suggestion("a.test"))+        #expect(suggester.calls == ["a.test", "a.test"])+    }++    @Test("An open on the hostname already being computed attaches to it")+    func openAttachesToAnInFlightAttempt() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.delay = .milliseconds(30)++        async let sweep: Void = coordinator.activationSweep()+        // The editor opens only once the sweep's attempt is in flight, which+        // the attempt itself reports rather than a sleep guessing at it.+        await suggester.waitForAttempts(1)+        async let opened = coordinator.suggestion(for: "a.test", origin: .open)++        await sweep+        #expect(await opened == Self.suggestion("a.test"))+        #expect(suggester.calls == ["a.test"])+    }++    @Test("A request pre-empts another hostname's attempt and waits for it to stop")+    func requestPreemptsAnotherHostname() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test"), Self.candidate("b.test")])+        mock.ruleSuggestionCandidatesResult = .success([+            Self.candidate("a.test", capturedAt: 20), Self.candidate("b.test", capturedAt: 10),+        ])+        suggester.suggestions = ["b.test": Self.suggestion("b.test")]+        suggester.delay = .milliseconds(50)++        async let sweep: Void = coordinator.activationSweep()+        await suggester.waitForAttempts(1)+        let delivered = await coordinator.suggestion(for: "b.test", origin: .request)+        await sweep++        #expect(delivered == Self.suggestion("b.test"))+        // The pre-empted hostname is left attemptable (Q28) and the two+        // attempts never overlapped (Req 5.11).+        #expect(coordinator.isAttempted("a.test") == false)+        #expect(suggester.peakConcurrent == 1)+    }++    @Test("Cancelling the caller does not cancel the attempt")+    func callerCancellationDoesNotCancelTheAttempt() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.delay = .milliseconds(30)++        let caller = Task { await coordinator.suggestion(for: "a.test", origin: .request) }+        await suggester.waitForAttempts(1)+        caller.cancel()+        _ = await caller.value++        // The attempt is the coordinator's, not the caller's: it finishes and+        // its result is held for the next open.+        while coordinator.held(for: "a.test") == nil { await Task.yield() }+        #expect(coordinator.held(for: "a.test") == Self.suggestion("a.test"))+    }++    // MARK: - Resign-active and the budget (Reqs 5.2, 5.3, 5.4)++    @Test("Resigning active cancels a background attempt and leaves it unattempted")+    func resignActiveCancelsBackgroundOnly() async {+        let (coordinator, _, suggester, environment, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.delay = .milliseconds(50)++        async let sweep: Void = coordinator.activationSweep()+        await suggester.waitForAttempts(1)+        environment.elapsed = .milliseconds(250)+        coordinator.resignActive()+        await sweep++        #expect(coordinator.isAttempted("a.test") == false)+        #expect(coordinator.held(for: "a.test") == nil)+        // A cancelled attempt still spends what it ran for (Req 5.2); the+        // suggester returned nothing, so the clock is the only measure.+        #expect(coordinator.budgetSpent == .milliseconds(250))+    }++    @Test("Resigning active leaves a reader-initiated attempt running")+    func resignActiveLeavesRequestsAlone() async {+        let (coordinator, mock, suggester, _, _) = makeSUT()+        mock.ruleSuggestionCandidatesResult = .success([Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        suggester.delay = .milliseconds(30)++        async let requested = coordinator.suggestion(for: "a.test", origin: .request)+        await suggester.waitForAttempts(1)+        coordinator.resignActive()++        #expect(await requested == Self.suggestion("a.test"))+        #expect(coordinator.isAttempted("a.test"))+    }++    @Test("The budget is charged what the attempt reported spending")+    func budgetIsChargedFromModelPhase() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test", capturedAt: 20),+                         Self.candidate("b.test", capturedAt: 10)])+        suggester.modelPhase = .milliseconds(400)++        await coordinator.activationSweep()+        #expect(coordinator.budgetSpent == .milliseconds(800))+    }++    @Test("A timed-out attempt is attempted, and charged what the timeout reported")+    func timeoutIsAttemptedAndCharged() async {+        let (coordinator, _, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.failure = AttemptTimeout(modelPhase: .milliseconds(900))++        await coordinator.activationSweep()++        #expect(coordinator.isAttempted("a.test"))+        #expect(coordinator.held(for: "a.test") == nil)+        #expect(coordinator.budgetSpent == .milliseconds(900))+    }++    @Test("An exhausted budget stops the sweep but not the reader's request")+    func exhaustedBudgetStopsBackgroundOnly() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.modelPhase = RuleSuggestionBounds.runTimeBudget+        await coordinator.activationSweep()+        #expect(suggester.calls == ["a.test"])++        mock.ruleSuggestionCandidatesResult = .success([+            Self.candidate("a.test", capturedAt: 5), Self.candidate("b.test", capturedAt: 10),+        ])+        await coordinator.activationSweep()+        #expect(suggester.calls == ["a.test"])++        suggester.suggestions = ["b.test": Self.suggestion("b.test")]+        #expect(await coordinator.suggestion(for: "b.test", origin: .request) != nil)+        #expect(suggester.calls == ["a.test", "b.test"])+    }++    // MARK: - Dismissal passthrough (Reqs 2.7, 2.8, 6.5)++    @Test("Dismissal is per side and survives invalidation")+    func dismissalIsPerSideAndSurvivesInvalidation() async {+        let (coordinator, mock, suggester, _, _) = makeSUT(+            candidates: [Self.candidate("a.test")])+        suggester.suggestions = ["a.test": Self.suggestion("a.test")]+        await coordinator.activationSweep()++        coordinator.dismiss(hostname: "a.test", side: .title)+        #expect(coordinator.isDismissed(hostname: "a.test", side: .title))+        #expect(coordinator.isDismissed(hostname: "a.test", side: .url) == false)++        mock.ruleSuggestionCandidatesResult = .success(+            [Self.candidate("a.test", entryCount: 9)])+        await coordinator.reconcile()+        #expect(coordinator.isDismissed(hostname: "a.test", side: .title))++        coordinator.clearDismissal(hostname: "a.test", side: .title)+        #expect(coordinator.isDismissed(hostname: "a.test", side: .title) == false)+    }+}
Asterism/AsterismTests/RuleSuggestionLogTests.swift Added +38 / -0
diff --git a/Asterism/AsterismTests/RuleSuggestionLogTests.swift b/Asterism/AsterismTests/RuleSuggestionLogTests.swiftnew file mode 100644index 0000000..3a5737f--- /dev/null+++ b/Asterism/AsterismTests/RuleSuggestionLogTests.swift@@ -0,0 +1,38 @@+import AsterismIntelligence+import Foundation+import Testing+@testable import Asterism++/// The suggestion log's two pure helpers. Everything else in it writes to+/// `OSLog`, which a test cannot read back.+@Suite("RuleSuggestionLog")+struct RuleSuggestionLogTests {++    @Test("A whole number of milliseconds survives the round trip",+          arguments: [0, 1, 250, 999, 1_000, 1_234, 10_000, 60_000] as [Int64])+    func millisecondsRoundTrip(_ milliseconds: Int64) {+        #expect(RuleSuggestionLog.milliseconds(.milliseconds(milliseconds)) == milliseconds)+    }++    @Test("Sub-millisecond remainders truncate rather than round")+    func millisecondsTruncates() {+        #expect(RuleSuggestionLog.milliseconds(.microseconds(1_500)) == 1)+        #expect(RuleSuggestionLog.milliseconds(.microseconds(999)) == 0)+        // The bounds this feature is written in: the attempt timeout and the+        // run budget both read as their plain millisecond counts.+        #expect(RuleSuggestionLog.milliseconds(RuleSuggestionBounds.attemptTimeout) == 10_000)+        #expect(RuleSuggestionLog.milliseconds(RuleSuggestionBounds.runTimeBudget) == 60_000)+    }++    @Test("A failure is described by its type and its value")+    func failureDescription() {+        let error = StubRuleSuggestionModelClientError.contextWindowOverflow+        let described = RuleSuggestionLog.describe(error)++        #expect(described.contains("StubRuleSuggestionModelClientError"))+        #expect(described.contains("contextWindowOverflow"))+        // One spelling, shared with the package so a model client's default+        // `describe(_:)` and this read the same.+        #expect(described == SuggestionFailure.describe(error))+    }+}
Asterism/AsterismTests/UITestLaunchSupportTests.swift Modified +28 / -0
diff --git a/Asterism/AsterismTests/UITestLaunchSupportTests.swift b/Asterism/AsterismTests/UITestLaunchSupportTests.swiftindex ee6e1dd..5f89a40 100644--- a/Asterism/AsterismTests/UITestLaunchSupportTests.swift+++ b/Asterism/AsterismTests/UITestLaunchSupportTests.swift@@ -2,6 +2,7 @@ import Foundation import Testing @testable import Asterism import AsterismCore+import AsterismIntelligence  @MainActor @Suite("UI test launch validation")@@ -205,6 +206,33 @@ struct UITestLaunchSupportTests {         }         #expect(!message.isEmpty)     }++    // MARK: - The scripted suggestion client (rule-suggestion)++    @Test("The launch environment picks the scripted model client, or none at all")+    func suggestionClientMapping() {+        let canned = UITestLaunchSupport.suggestionClient(+            environmentProvider: StubProcessEnvironment(+                values: [UITestLaunchSupport.suggestionKey: "canned"]))+        #expect(canned?.availability() == .available)+        #expect((canned as? StubRuleSuggestionModelClient)?.proposal+            == UITestLaunchSupport.cannedSuggestionProposal)++        let unavailable = UITestLaunchSupport.suggestionClient(+            environmentProvider: StubProcessEnvironment(+                values: [UITestLaunchSupport.suggestionKey: "unavailable"]))+        #expect(unavailable?.availability().isAvailable == false)++        // Every production launch, and every UI test that asked for nothing:+        // the on-device client stands, and no stub is substituted for it.+        let unasked = UITestLaunchSupport.suggestionClient(+            environmentProvider: StubProcessEnvironment(values: [:]))+        let unrecognised = UITestLaunchSupport.suggestionClient(+            environmentProvider: StubProcessEnvironment(+                values: [UITestLaunchSupport.suggestionKey: "nonsense"]))+        #expect(unasked == nil)+        #expect(unrecognised == nil)+    } }  private struct StubProcessEnvironment: ProcessEnvironmentProviding {
Asterism/AsterismUITests/ComposedSurfaceUITests.swift Modified +98 / -0
diff --git a/Asterism/AsterismUITests/ComposedSurfaceUITests.swift b/Asterism/AsterismUITests/ComposedSurfaceUITests.swiftindex d56b82c..3a29795 100644--- a/Asterism/AsterismUITests/ComposedSurfaceUITests.swift+++ b/Asterism/AsterismUITests/ComposedSurfaceUITests.swift@@ -82,6 +82,24 @@ final class ComposedSurfaceUITests: XCTestCase {         XCTFail(message, file: file, line: line)     } +    /// Scrolls the surface until the element is in the tree. A lazy Form does not+    /// instantiate rows below the fold, so "does not exist" and "has not been+    /// scrolled to" look alike; this asks the second question first. The message+    /// is an autoclosure so a diagnosis can be taken at the moment of failure.+    @discardableResult+    private func scrollUntilExists(+        _ element: XCUIElement, _ message: @autoclosure () -> String,+        file: StaticString = #filePath, line: UInt = #line+    ) -> Bool {+        if element.waitForExistence(timeout: 5) { return true }+        for _ in 0..<8 {+            app.swipeUp()+            if element.waitForExistence(timeout: 2) { return true }+        }+        XCTFail(message(), file: file, line: line)+        return false+    }+     /// The URL details are expanded when the editor's own controls are on screen.     private func requireURLEditorExpanded(         _ message: String, file: StaticString = #filePath, line: UInt = #line@@ -362,6 +380,86 @@ final class ComposedSurfaceUITests: XCTestCase {                     "Committing Articles leaves the composed surface")     } +    // MARK: - Rule suggestion (rule-suggestion Reqs 1.1, 2.1, 2.2, 4.1, 6.1)++    /// Relaunches the same seeded scenario with a scripted model client. `setUp`+    /// has already launched it without one, which is what every other test here+    /// wants — a suggestion must be asked for explicitly.+    private func relaunch(withSuggestion mode: String) {+        terminateAndWaitForExit(app)+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-composed"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launchEnvironment["ASTERISM_UI_TEST_SUGGESTION"] = mode+        app.launch()+    }++    /// A verified suggestion is applied when the editor opens, wears its marker,+    /// and gives way to the reader's first edit.+    func testCannedSuggestionSeedsTheEditorAndYieldsToAnEdit() {+        relaunch(withSuggestion: "canned")+        openComposedSurfaceViaPill()++        require(any("composed-title-suggested"),+                "A held suggestion opens the title editor already filled and marked",+                timeout: 20)+        require(app.buttons["composed-suggest"],+                "The on-request action is offered while the model is available")++        // Req 2.2: changing the selection retires the marker and leaves an+        // ordinary hand-authored selection behind. Done before the URL+        // assertions below, which scroll away from the chip row.+        app.buttons["composed-title-chip-0"].tap()+        requireGone(any("composed-title-suggested"),+                    "Editing a suggested side removes its marker")+        require(app.buttons["composed-teaching-confirm"],+                "The editor keeps the controls a hand-authored selection has")++        // Req 2.1: the URL side is marked in its own right — the canned proposal+        // identifies the Work in the first path component — and the marker is+        // not hidden by the disclosure the reader closes over it.+        scrollUntilExists(+            any("composed-url-suggested"),+            """+            The suggested URL rule should carry its own marker \+            (disclosure: \(self.any("composed-url-disclosure").exists), \+            editor: \(self.app.buttons["composed-url-path-chip-0"].exists), \+            summary: \(self.any("composed-url-collapsed-summary").exists), \+            clear: \(self.app.buttons["composed-url-clear"].exists))+            """)+        toggleURLDisclosure()+        requireGone(app.buttons["composed-url-path-chip-0"], "Collapsing hides the URL editor")+        scrollUntilExists(+            any("composed-url-suggested"),+            "The URL marker should stay visible while the URL details are collapsed")+    }++    /// Req 4.1: with no model, the surface is exactly what it is today.+    func testModelUnavailableLeavesTheSurfaceUnchanged() {+        relaunch(withSuggestion: "unavailable")+        openComposedSurfaceViaPill()++        XCTAssertFalse(+            any("composed-title-suggested").waitForExistence(timeout: 8),+            "No title suggestion is applied while the model reports itself away")++        // The URL marker has two homes — inside the details and on the collapsed+        // header — so the absence is checked with each of them on screen rather+        // than against a section that is not in the tree at all. The default+        // whole-title selection sources no chapter, so the details open by+        // themselves first.+        requireURLEditorExpanded("The URL details open as the chapter remedy")+        XCTAssertFalse(any("composed-url-suggested").exists,+                       "No URL marker inside the open URL details")+        toggleURLDisclosure()+        requireGone(app.buttons["composed-url-path-chip-0"], "Collapsing hides the URL editor")+        require(any("composed-url-disclosure"), "The collapsed URL header stays on screen")+        XCTAssertFalse(any("composed-url-suggested").exists,+                       "No URL marker on the collapsed URL header either")++        XCTAssertFalse(app.buttons["composed-suggest"].exists,+                       "The on-request action is not offered at all")+    }+     // MARK: - Work detail → Review URL identity → Recalculate (Req 7.1, 7.2)      func testWorkDetailReviewAndRecalculate() {
CHANGELOG.md Modified +22 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 0808aea..5334801 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Added++- **Suggested rules in the composed teaching editor (rule-suggestion,+  T-2156).** On devices where Apple Intelligence is available, the app proposes+  a title rule and a URL rule for an untaught hostname from its captures, using+  the on-device model. Suggestions are computed in the background after the app+  comes to the foreground (bounded depth and time budget, never in Low Power+  Mode or under thermal pressure) and verified against every capture on the+  hostname before they are shown. Opening the editor on such a hostname+  pre-fills the untaught side with the suggestion under a "Suggested" marker;+  a **Suggest** action asks for one on demand; clearing restores the editor+  exactly as it was; a suggestion arriving after you have started editing a+  side leaves that side alone; a side you save differently is not suggested+  again. Nothing is written until you save. Where the model is unavailable, or+  no suggestion verifies, the editor is unchanged and Suggest reports that no+  suggestion is available. The share extension is untouched.+  Internally this adds the `AsterismIntelligence` package product (the first+  `FoundationModels` use in the tree; linked by the app only), a+  `ruleSuggestionCandidates(hostnames:)` read on `LibraryProviding`, and+  diagnostic logging under `subsystem:me.nore.ig.Asterism+  category:RuleSuggestion`. No schema, migration or archive change.+ ### Upgrading  - **Your library migrates to schema V5 the first time you open the app after
CLAUDE.md Modified +9 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex c205135..3ed7403 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -40,7 +40,7 @@ overwrites state. A restore is irreversible; a container download is not. Use the `Makefile` for everything. Do not hand-roll `xcodebuild` or `swift test` invocations where a target exists. -- `make test-core` — AsterismCore package tests (host, fast, safe)+- `make test-core` — AsterismCore package tests (host, fast, safe). Since `rule-suggestion` the package has a second product, `AsterismIntelligence` (linked by the app and `AsterismTests` only — never the share extension), and its tests include **one live Apple Intelligence call** that degrades to a `withKnownIssue` when the host has no model available. - `make test-quick` — unit-test bundle only (simulator) - `make test` / `make test-ui` — full suites (simulator) - `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~20 minutes** (1,213 s measured 2026-08-09, including a 165 s release build): 62% of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with four accepted breaches reported as `withKnownIssue` known issues rather than failures (Req 10.1's settling pass, Req 5.5's three diagnosis re-derivations); `RUNS=3` therefore completes all three runs. See `specs/retire-migration-chain/verification-run.md` for the numbers and `docs/agent-notes/testing.md` for recording a band.@@ -79,6 +79,14 @@ shipped app. Unit tests must use `Development` — `Personal` has no testability and the `Asterism Personal` scheme deliberately excludes the unit-test bundle for that reason. +### Rule-suggestion diagnostics++The suggestion pipeline logs every attempt, refusal, drop point and settle+(with the model phase in ms) under `subsystem:me.nore.ig.Asterism+category:RuleSuggestion`. Reader content (titles, URLs, proposal text) is+readable in `Development` builds only; reasons and numbers are always readable.+Filter on that in Console.app with the phone selected.+ ## Performance measurement  Measure in release. `swift test` defaults to debug, and the fixtures are guarded
Packages/AsterismCore/Package.swift Modified +14 / -0
diff --git a/Packages/AsterismCore/Package.swift b/Packages/AsterismCore/Package.swiftindex 759048a..db3153a 100644--- a/Packages/AsterismCore/Package.swift+++ b/Packages/AsterismCore/Package.swift@@ -10,11 +10,21 @@ let package = Package(     ],     products: [         .library(name: "AsterismCore", targets: ["AsterismCore"]),+        .library(name: "AsterismIntelligence", targets: ["AsterismIntelligence"]),         .library(name: "ConstellationKit", targets: ["ConstellationKit"]),         .executable(name: "AsterismStoreTestHelper", targets: ["AsterismStoreTestHelper"]),     ],     targets: [         .target(name: "AsterismCore"),+        // On-device model work: the availability gate, the model client and the+        // pure state around a rule suggestion. It imports `FoundationModels`,+        // which is exactly why it is a separate product: the share extension+        // links `AsterismCore` and must never link the model framework+        // (Req 4.4, rule-suggestion Decision 2).+        .target(+            name: "AsterismIntelligence",+            dependencies: ["AsterismCore"]+        ),         // The Constellation design language: tokens and SwiftUI components.         // Deliberately independent of AsterismCore — Core stays SwiftUI-free,         // and the share extension can import this without importing the store@@ -29,6 +39,10 @@ let package = Package(             dependencies: ["AsterismCore"],             resources: [.copy("Fixtures")]         ),+        .testTarget(+            name: "AsterismIntelligenceTests",+            dependencies: ["AsterismIntelligence"]+        ),         .testTarget(             name: "ConstellationKitTests",             dependencies: ["ConstellationKit"]
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Modified +6 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex 9c59b20..ee9a295 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -19,6 +19,12 @@ public protocol LibraryProviding: Sendable {     /// appearing twice (Q24) — row-level duplication is Library Check's subject.     func sites() async throws -> [SiteSnapshot] +    /// One row per hostname for the rule-suggestion sweep and its invalidation+    /// pass (Reqs 1.6, 5.1, 5.5, Q37): Site mode, live rule versions, capture+    /// count and newest capture. `nil` reads every hostname; a set reads only+    /// those.+    func ruleSuggestionCandidates(hostnames: Set<String>?) async throws -> [RuleSuggestionCandidate]+     /// The configured work-type list for the settings screen and the editor's     /// picker: active entries, plus the removed ones works still use (Reqs 1.1,     /// 1.7). One row per identity — duplicate rows are folded, merged entries
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift Added +117 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swiftnew file mode 100644index 0000000..00d50a5--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift@@ -0,0 +1,117 @@+import Foundation+import SwiftData++// The rule-suggestion candidate read (Reqs 1.6, 5.1, 5.5, Q37).+//+// One row per hostname carrying exactly what the suggestion coordinator needs+// and nothing that would make it expensive: the sweep picks auto-eligible+// hostnames from it (not in articles mode, at least one capture, a rule+// missing on one side) and orders them by most recent capture, and+// `reconcile` compares the same fields to the fingerprint it recorded when an+// attempt started. Neither `sites()` nor `RecentPresentation` can serve: the+// first carries no URL-rule presence or capture recency, the second is capped+// and would miss older untaught sites.++extension LibraryRepository {++    /// Candidate rows for the rule-suggestion sweep and its invalidation pass.+    ///+    /// `hostnames == nil` is the sweep's read: every hostname in the store.+    /// A set is `reconcile`'s read: only the hostnames the ledger tracks. An+    /// empty set answers immediately, without taking the lock — the ledger+    /// tracks nothing for most of a run.+    ///+    /// Rows come back sorted by hostname, so a caller comparing two reads+    /// compares them in a stable order; recency ordering is the sweep's own+    /// business.+    public func ruleSuggestionCandidates(+        hostnames: Set<String>?+    ) async throws -> [RuleSuggestionCandidate] {+        if let hostnames, hostnames.isEmpty { return [] }+        return try await withLockedContext(+            mode: .shared, operation: "reading rule suggestion candidates"+        ) { context in+            let rows: [Site]+            if let hostnames {+                let wanted = Array(hostnames)+                rows = try context.fetch(+                    FetchDescriptor<Site>(predicate: #Predicate { wanted.contains($0.hostname) }))+            } else {+                rows = try context.fetch(FetchDescriptor<Site>())+            }+            // The same winner rule every whole-store pass uses: a duplicated+            // hostname is Library Check's subject, not this read's, and a+            // suggestion is about the row a teach would write to.+            return try SiteResolutionOrder.winnersByHostname(rows)+                .values+                .compactMap { site -> RuleSuggestionCandidate? in+                    // A mode outside the closed set has no honest row here, the+                    // same judgement `sites()` makes. Coercing it to `.untaught`+                    // would make it auto-eligible and propose fiction rules for+                    // a state nothing here understands (Q15). Its absence reads+                    // as a mismatch to `reconcile`, which is the safe direction.+                    guard let mode = SiteMode(rawValue: site.modeRaw) else { return nil }+                    return try Self.ruleSuggestionCandidate(+                        site, mode: mode, context: context)+                }+                .sorted { $0.hostname < $1.hostname }+        }+    }++    /// One hostname's row. `Site.entries` is never traversed — faulting every+    /// Entry for a hostname is what that relationship stays internal to prevent+    /// (Q17) — so the two capture facts come from two scoped Entry reads:+    /// a count, and the newest row alone. SwiftData has no aggregate max.+    private static func ruleSuggestionCandidate(+        _ site: Site, mode: SiteMode, context: ModelContext+    ) throws -> RuleSuggestionCandidate {+        let hostname = site.hostname+        let entryCount = try context.fetchCount(+            FetchDescriptor<Entry>(predicate: #Predicate { $0.hostname == hostname }))+        var newest = FetchDescriptor<Entry>(+            predicate: #Predicate { $0.hostname == hostname },+            sortBy: [SortDescriptor(\.firstCapturedAt, order: .reverse)])+        newest.fetchLimit = 1+        return RuleSuggestionCandidate(+            hostname: hostname,+            siteMode: mode,+            titleRuleVersion: site.activePattern?.version,+            urlRuleVersion: site.urlRuleValues.first(where: \.isCurrent)?.version,+            entryCount: entryCount,+            latestCaptureAt: try context.fetch(newest).first?.firstCapturedAt)+    }+}++/// What the rule-suggestion sweep sees of one hostname (Reqs 1.6, 5.1, 5.5).+///+/// Deliberately five facts and no rule content: eligibility and invalidation+/// are the only questions it answers, and the attempt itself re-reads the+/// hostname through `projectComposedTeaching`.+public struct RuleSuggestionCandidate: Equatable, Sendable {+    public let hostname: String+    public let siteMode: SiteMode+    /// Version of the Site's active title pattern; nil where it has none.+    public let titleRuleVersion: Int?+    /// Version of the Site's current URL rule; nil where it has none.+    public let urlRuleVersion: Int?+    public let entryCount: Int+    /// The newest capture's `firstCapturedAt`; nil where the hostname has no+    /// captures.+    public let latestCaptureAt: Date?++    public init(+        hostname: String,+        siteMode: SiteMode,+        titleRuleVersion: Int?,+        urlRuleVersion: Int?,+        entryCount: Int,+        latestCaptureAt: Date?+    ) {+        self.hostname = hostname+        self.siteMode = siteMode+        self.titleRuleVersion = titleRuleVersion+        self.urlRuleVersion = urlRuleVersion+        self.entryCount = entryCount+        self.latestCaptureAt = latestCaptureAt+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift Added +168 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swiftnew file mode 100644index 0000000..dd44a24--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift@@ -0,0 +1,168 @@+import Foundation+import FoundationModels++/// The on-device model, behind the suggester's seam. Everything it does runs on+/// this device: no capture title or URL leaves it (Req 4.3).+///+/// One `LanguageModelSession` per call. There is no conversation to keep — each+/// hostname is an independent question — and a fresh session keeps the token+/// window to the instructions plus one corpus.+public struct FoundationRuleSuggestionModelClient: RuleSuggestionModelClient {+    public init() {}++    // MARK: - Availability (Req 4.1, Q41)++    public func availability() -> ModelAvailability {+        Self.availability(from: SystemLanguageModel.default.availability)+    }++    /// The reason is kept for logging only: Req 4.1 forbids telling the reader+    /// anything about the model being absent.+    static func availability(from availability: SystemLanguageModel.Availability) -> ModelAvailability {+        switch availability {+        case .available:+            .available+        case .unavailable(let reason):+            switch reason {+            case .deviceNotEligible: .unavailable(reason: "deviceNotEligible")+            case .appleIntelligenceNotEnabled: .unavailable(reason: "appleIntelligenceNotEnabled")+            case .modelNotReady: .unavailable(reason: "modelNotReady")+            @unknown default: .unavailable(reason: "unknown")+            }+        }+    }++    // MARK: - The call++    public func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal {+        let session = LanguageModelSession(instructions: Self.instructions)+        // `Response<Content>` is not Sendable, so only its content leaves here.+        let response = try await session.respond(to: Self.prompt(for: corpus),+                                                 generating: RuleProposal.self,+                                                 options: Self.generationOptions)+        return response.content+    }++    /// Greedy sampling: the same corpus must yield the same proposal, or a+    /// suggestion the reader dismissed could come back different next launch+    /// (Q24).+    static let generationOptions = GenerationOptions(sampling: .greedy)++    /// Whether an error is the model saying the input was too long. The+    /// suggester answers it by halving the corpus and retrying, which is why+    /// the case has to survive as a type rather than a message (Req 3.7).+    ///+    /// It is the client's job rather than the suggester's so that nothing above+    /// this seam has to import `FoundationModels`.+    public func isContextWindowOverflow(_ error: any Error) -> Bool {+        guard let generation = error as? LanguageModelSession.GenerationError else { return false }+        if case .exceededContextWindowSize = generation { return true }+        return false+    }++    /// The failure as the log should carry it. `GenerationError`'s cases are+    /// the difference between "the model refused this corpus" and "Apple+    /// Intelligence is not there", and the case name is the only part of that+    /// which reads at a glance — so it is named here, where the framework is+    /// imported, rather than left to `String(describing:)` above the seam.+    public func describe(_ error: any Error) -> String {+        guard let generation = error as? LanguageModelSession.GenerationError else {+            return SuggestionFailure.describe(error)+        }+        return "GenerationError.\(Self.caseName(of: generation)): \(String(describing: generation))"+    }++    static func caseName(of error: LanguageModelSession.GenerationError) -> String {+        switch error {+        case .exceededContextWindowSize: "exceededContextWindowSize"+        case .assetsUnavailable: "assetsUnavailable"+        case .guardrailViolation: "guardrailViolation"+        case .unsupportedGuide: "unsupportedGuide"+        case .unsupportedLanguageOrLocale: "unsupportedLanguageOrLocale"+        case .decodingFailure: "decodingFailure"+        case .rateLimited: "rateLimited"+        case .concurrentRequests: "concurrentRequests"+        case .refusal: "refusal"+        @unknown default: "unknown"+        }+    }++    // MARK: - Prompting++    /// Fixed instructions: the task, the verbatim-copying rule that makes+    /// `ProposalLocator` able to find the answers, and three worked examples —+    /// the third an opaque address whose chapter lives only in the title+    /// (Decision 1).+    static let instructions = """+    You are given the titles and web addresses of pages captured from one \+    website. Each page is one chapter or part of a longer story that is \+    published in instalments.++    Answer about the FIRST title and the FIRST address only, with four pieces \+    of text:+    - workName: the part of the first title that names the story.+    - chapterText: the part of the first title that identifies the chapter or \+    part, or empty if the title has none. It is often nothing more than a \+    number or a decimal such as 12, 1.00 or 4.3, frequently at the very start \+    of the title.+    - urlWorkIdentity: the part of the first address that identifies the story.+    - urlSequenceText: the part of the first address that identifies the \+    chapter or its number, or empty if the address has none.++    Copy text exactly as it appears, character for character. Do not translate \+    it, reword it, reorder it, correct its spelling, or change its punctuation, \+    spacing or capitalisation. If a value does not appear in the first title or \+    the first address, answer with empty text.++    workName and chapterText come only from the first title; urlWorkIdentity \+    and urlSequenceText come only from the first address. Judge the title and \+    the address separately: an address that is just a bare number or code names \+    neither the story nor its chapter, so both address answers are empty — but \+    the title still gets its own workName and chapterText. The website's own \+    domain name is never urlWorkIdentity. Site names, verbs \+    such as "Read", and separators are not part of the story's name.++    The later examples are there only to show which parts change from chapter \+    to chapter. Never answer with text taken from them.++    Example A+    1. title: Sylver Seeker - Chapter 12: The Long Road+       url: https://example.net/novel/sylver-seeker/chapter-12+    2. title: Sylver Seeker - Chapter 11: Ashes+       url: https://example.net/novel/sylver-seeker/chapter-11+    Answer: workName "Sylver Seeker", chapterText "Chapter 12: The Long Road", \+    urlWorkIdentity "sylver-seeker", urlSequenceText "12".++    Example B+    1. title: Deep Blue Sky+       url: https://stories.example.com/read?story=deep-blue-sky&part=4+    2. title: Deep Blue Sky+       url: https://stories.example.com/read?story=deep-blue-sky&part=3+    Answer: workName "Deep Blue Sky", chapterText "", urlWorkIdentity \+    "deep-blue-sky", urlSequenceText "4".++    Example C+    1. title: Read Episode 7 - Moonlit Harbour | Pagelight+       url: https://pagelight.example/en/chapters/58210034+    2. title: Read Episode 6 - Moonlit Harbour | Pagelight+       url: https://pagelight.example/en/chapters/58209871+    Answer: workName "Moonlit Harbour", chapterText "Episode 7", \+    urlWorkIdentity "", urlSequenceText "".+    """++    /// The corpus as the model sees it: numbered examples, anchor first, so+    /// "the first title" in the instructions is unambiguous.+    static func prompt(for corpus: SuggestionCorpus) -> String {+        let examples = corpus.examples.enumerated().map { index, example in+            """+            \(index + 1). title: \(example.title)+               url: \(example.rawURL)+            """+        }+        return """+        Pages captured from \(corpus.hostname):++        \(examples.joined(separator: "\n"))+        """+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/ProposalLocator.swift Added +54 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/ProposalLocator.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/ProposalLocator.swiftnew file mode 100644index 0000000..27604e2--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/ProposalLocator.swift@@ -0,0 +1,54 @@+import Foundation++/// Turns a substring the model claimed to have copied into a span over the text+/// it was copied from (Decision 1).+///+/// Constrained decoding guarantees the *shape* of a `RuleProposal`, not that a+/// value occurs in the input, so every field is re-found here. A field is+/// accepted only when it occurs exactly once: a repeated substring — a chapter+/// number that also appears in the work name — has no single span, and guessing+/// one would put a span the reader never sanctioned into a rule (Req 3.1).+public enum ProposalLocator {+    /// Where a field sits in one piece of text. `absent` and `ambiguous` are+    /// both "no span", but a caller searching several candidate texts has to+    /// tell them apart: absent in this one means keep looking, ambiguous means+    /// the field is unusable whatever the others say.+    public enum Placement: Sendable, Equatable {+        case unique(Range<Int>)+        case absent+        case ambiguous+    }++    /// Where `text` occurs in `source`. Empty text is `absent` in every source:+    /// "no chapter" is spelled as an empty field (Q39), never as a span.+    ///+    /// Offsets count `Character`s so they line up with+    /// `ComposedTeachingPresentation.TitleSegment.range`, which the assembler+    /// feeds them to. Comparison is `Character` equality, so a precomposed+    /// needle matches a decomposed source, and a multi-scalar emoji counts as+    /// one position.+    public static func place(_ text: String, in source: String) -> Placement {+        let needle = Array(text)+        guard !needle.isEmpty else { return .absent }+        let haystack = Array(source)+        guard needle.count <= haystack.count else { return .absent }++        var match: Range<Int>?+        // Overlapping occurrences count separately: "aa" in "aaa" is ambiguous.+        for start in 0 ... (haystack.count - needle.count) {+            guard haystack[start ..< start + needle.count].elementsEqual(needle) else { continue }+            if match != nil { return .ambiguous }+            match = start ..< start + needle.count+        }+        guard let match else { return .absent }+        return .unique(match)+    }++    /// The character range of `text` in `source`, or nil when `text` is empty,+    /// absent, or occurs more than once — the answer a caller with one text to+    /// search wants.+    public static func locate(_ text: String, in source: String) -> Range<Int>? {+        guard case .unique(let range) = place(text, in: source) else { return nil }+        return range+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/RuleProposal.swift Added +178 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleProposal.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleProposal.swiftnew file mode 100644index 0000000..061929b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleProposal.swift@@ -0,0 +1,178 @@+import Foundation+import FoundationModels+import Synchronization++/// What the model returns: the *text* of each field, copied out of the anchor+/// capture, never an offset.+///+/// Constrained decoding guarantees the shape but not that a value is a+/// substring, so `ProposalLocator` re-finds each one in the anchor and drops+/// anything it cannot place uniquely (Decision 1). Empty means "none" — an+/// optional field would cost schema room inside the 4,096-token window for+/// nothing (Q39).+///+/// The declaration order is deliberate: work before chapter, title before URL.+@Generable+public struct RuleProposal: Sendable, Equatable {+    @Guide(description: "Exact text from the first title that names the work, copied verbatim")+    public var workName: String++    @Guide(description: "Exact text from the first title that identifies the chapter or part, verbatim, or empty if none")+    public var chapterText: String++    @Guide(description: "Exact text from the first URL that identifies the work, verbatim")+    public var urlWorkIdentity: String++    @Guide(description: "Exact text from the first URL that identifies the chapter or sequence, verbatim, or empty if none")+    public var urlSequenceText: String++    public init(workName: String = "", chapterText: String = "",+                urlWorkIdentity: String = "", urlSequenceText: String = "") {+        self.workName = workName+        self.chapterText = chapterText+        self.urlWorkIdentity = urlWorkIdentity+        self.urlSequenceText = urlSequenceText+    }+}++/// The seam between the suggester and the on-device model. Everything above it+/// is testable on the host without Apple Intelligence being present.+public protocol RuleSuggestionModelClient: Sendable {+    /// Re-read on every activation: `.modelNotReady` is transient (Q41).+    func availability() -> ModelAvailability+    func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal++    /// Whether an error is this client saying the input was too long. The+    /// suggester answers it by halving the corpus and retrying (Req 3.7), and+    /// asks the client because only the client knows what its own failures look+    /// like — the suggester must never have to name a concrete client or import+    /// `FoundationModels` to find out.+    func isContextWindowOverflow(_ error: any Error) -> Bool++    /// A loggable account of a failure from this client, for the same reason:+    /// naming the *case* of a framework error takes the framework's own types,+    /// and only the client has them.+    func describe(_ error: any Error) -> String+}++extension RuleSuggestionModelClient {+    /// A client whose model has no context limit worth retrying against never+    /// overflows.+    public func isContextWindowOverflow(_ error: any Error) -> Bool { false }++    /// The type and the value: as much as a client that knows nothing special+    /// about its own errors can say.+    public func describe(_ error: any Error) -> String {+        SuggestionFailure.describe(error)+    }+}++// The stub is a test double, and the same gate the package's other fixtures+// carry (`M4PerformanceFixture`) keeps it out of a shipping build: a release+// binary has no business carrying a scripted model client that answers+// `propose` with whatever it was handed.+#if DEBUG || ASTERISM_PERFORMANCE_TESTING++/// A model client that returns what it was told to, for tests and for the UI+/// test launch environment.+///+/// Two ways to script it. The single canned `proposal`/`error` answers every+/// call the same way; `results`, when non-empty, is consumed one entry per call+/// with the last entry repeating, which is what a halve-then-succeed sequence+/// needs. Either way `recorder` keeps every corpus `propose` was given, so a+/// test can assert on both the count and the shrinking context.+public struct StubRuleSuggestionModelClient: RuleSuggestionModelClient {+    /// One scripted answer. The failure is constrained to `Sendable` so the+    /// script can cross isolation domains with the stub.+    public typealias ScriptedResult = Result<RuleProposal, any Error & Sendable>++    /// The stub's only mutable state. It lives behind a lock in a reference+    /// type so that copies of the (value-typed, `Sendable`) stub share one log+    /// and one position in the script.+    public final class Recorder: Sendable {+        private struct State {+            var corpora: [SuggestionCorpus] = []+            var nextResult = 0+        }++        private let state = Mutex(State())++        public init() {}++        /// Every corpus `propose` was called with, in order.+        public var recordedCorpora: [SuggestionCorpus] { state.withLock { $0.corpora } }+        public var callCount: Int { state.withLock { $0.corpora.count } }++        public func reset() { state.withLock { $0 = State() } }++        /// Logs the call and returns which scripted result it should get,+        /// clamped so the last entry repeats. Nil when the script is empty.+        fileprivate func record(_ corpus: SuggestionCorpus, scriptLength: Int) -> Int? {+            state.withLock { state in+                state.corpora.append(corpus)+                defer { state.nextResult += 1 }+                guard scriptLength > 0 else { return nil }+                return min(state.nextResult, scriptLength - 1)+            }+        }+    }++    public var availabilityResult: ModelAvailability+    public var proposal: RuleProposal?+    public var error: (any Error & Sendable)?+    /// Sleep before answering, so a test can exercise timeout and cancellation.+    public var delay: Duration?+    /// Answers consumed in order, the last one repeating. Takes precedence over+    /// `proposal` and `error` when non-empty.+    public var results: [ScriptedResult]+    public let recorder: Recorder++    public init(availability: ModelAvailability = .available,+                proposal: RuleProposal? = nil,+                error: (any Error & Sendable)? = nil,+                delay: Duration? = nil,+                results: [ScriptedResult] = [],+                recorder: Recorder = Recorder()) {+        self.availabilityResult = availability+        self.proposal = proposal+        self.error = error+        self.delay = delay+        self.results = results+        self.recorder = recorder+    }++    public func availability() -> ModelAvailability { availabilityResult }++    public func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal {+        // Recorded before the delay: a call that is cancelled or times out still+        // happened, and a test asserting "no model call" must see it.+        let scripted = recorder.record(corpus, scriptLength: results.count)+        if let delay {+            try await Task.sleep(for: delay)+        }+        if let scripted {+            return try results[scripted].get()+        }+        if let error {+            throw error+        }+        guard let proposal else {+            throw StubRuleSuggestionModelClientError.noCannedProposal+        }+        return proposal+    }++    public func isContextWindowOverflow(_ error: any Error) -> Bool {+        (error as? StubRuleSuggestionModelClientError) == .contextWindowOverflow+    }+}++public enum StubRuleSuggestionModelClientError: Error, Sendable, Equatable {+    /// The stub was asked for a proposal it was never given.+    case noCannedProposal+    /// The stub's stand-in for the model refusing an over-long input, so a test+    /// can drive Req 3.7's halve-and-retry without `FoundationModels`.+    case contextWindowOverflow+}++#endif
Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionBounds.swift Added +31 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionBounds.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionBounds.swiftnew file mode 100644index 0000000..69b3692--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionBounds.swift@@ -0,0 +1,31 @@+import Foundation++/// Every tunable this feature has, in one place, so the latency spike can+/// revise them without hunting through call sites (Q11, Q17, Q22, Q24).+///+/// All five values are provisional until `RuleSuggester.attempt(hostname:)` has+/// been measured on the `Personal` build; see `specs/rule-suggestion/prerequisites.md`.+public enum RuleSuggestionBounds {+    /// How many auto-eligible hostnames one activation sweep attempts (Q17).+    /// Positions 1–3 carry nearly all the value: the hostname the reader opens+    /// is almost always the one whose capture just landed.+    public static let backgroundSweepDepth = 3++    /// The cumulative wall-clock model time one app run may spend (Q17, Q31).+    /// Exhaustion stops the background sweep only — on-open and on-request+    /// attempts are reader-initiated and still start.+    public static let runTimeBudget: Duration = .seconds(60)++    /// How long one hostname's attempt may run, measured from the model request+    /// being issued (Q11). A stuck model call cannot pin resources for longer.+    public static let attemptTimeout: Duration = .seconds(10)++    /// How long after the editor appears a late result may still be applied+    /// (Q22). Past it the result is held and surfaced through the on-request+    /// action instead of mutating a selection the reader is already reading.+    public static let autoApplyWindow: Duration = .seconds(2)++    /// How many of a hostname's captures the model is shown, newest first+    /// (Q24). The newest is the anchor; the rest are context.+    public static let captureSampleCount = 5+}
Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift Added +377 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swiftnew file mode 100644index 0000000..484918a--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift@@ -0,0 +1,377 @@+import Foundation++/// The device conditions the sweep is gated on (Req 5.3) and whether the app is+/// active. They are passed in rather than read here so the ledger stays a pure+/// value type the host tests can drive.+public struct RuleSuggestionEnvironment: Sendable, Equatable {+    public var isActive: Bool+    public var isLowPowerMode: Bool+    public var thermalState: ProcessInfo.ThermalState++    public init(isActive: Bool = true, isLowPowerMode: Bool = false,+                thermalState: ProcessInfo.ThermalState = .nominal) {+        self.isActive = isActive+        self.isLowPowerMode = isLowPowerMode+        self.thermalState = thermalState+    }++    /// Req 5.3's gate: serious or critical stops the sweep, fair does not.+    var isThermallyConstrained: Bool {+        thermalState.rawValue >= ProcessInfo.ThermalState.serious.rawValue+    }+}++/// Why the ledger refused to start an attempt. Carried out of `start` rather+/// than re-derived by the caller: the state that decided it can have moved on+/// by the time the caller asks, and a log line that names a different reason+/// than the one applied is worse than no log line.+public enum RefusalReason: Sendable, Equatable, CustomStringConvertible {+    /// Attempted this run already (Req 5.7).+    case attempted+    /// The reader rejected both sides for this hostname (Reqs 2.8, 6.5).+    case bothSidesDismissed+    /// Another hostname's attempt holds the floor and this origin cannot+    /// pre-empt it (Req 5.11, Q55).+    case inFlight(hostname: String)+    /// The run's cumulative model-time budget is spent (Req 5.2, Q31).+    case budgetExhausted+    case notActive+    case lowPower+    case thermallyConstrained++    public var description: String {+        switch self {+        case .attempted: "already attempted this run"+        case .bothSidesDismissed: "both sides dismissed"+        case .inFlight(let hostname): "attempt in flight for \(hostname)"+        case .budgetExhausted: "run budget spent"+        case .notActive: "app not active"+        case .lowPower: "low power mode"+        case .thermallyConstrained: "thermally constrained"+        }+    }+}++/// What the caller must do about a requested attempt.+public enum AttemptStart: Sendable, Equatable {+    /// Run the attempt: the ledger has recorded it as in flight.+    case start+    /// An attempt for this hostname is already running; await its result.+    case attach+    /// Do nothing, for the reason the ledger actually applied.+    case refuse(RefusalReason)+    /// Cancel the running attempt for this hostname, settle it, then ask again.+    /// The ledger deliberately does not swap the in-flight record itself: the+    /// new attempt may not start until the cancelled one has terminated+    /// (Req 5.11).+    case preempt(hostname: String)++    /// The reason, for a caller that only wants to log it.+    public var refusalReason: RefusalReason? {+        guard case .refuse(let reason) = self else { return nil }+        return reason+    }+}++/// How an attempt ended. The distinction that matters is whether the hostname+/// counts as attempted: a timeout does, a cancellation the app itself caused+/// does not (Q28, Q45). Every ending charges the budget (Req 5.2).+public enum AttemptSettlement: Sendable, Equatable {+    case suggestion(RuleSuggestion)+    /// Settled with nothing to show: no valid suggestion, or the model failed.+    case noSuggestion+    case timedOut+    case cancelled+}++/// Every piece of per-run state this feature keeps, and the rules for moving+/// between its states. Pure and value-typed: the coordinator owns the `Task`s,+/// the clock and the library, the ledger owns only the bookkeeping.+///+/// Nothing here is ever written to disk — an app run is the whole lifetime.+public struct RuleSuggestionLedger: Sendable, Equatable {+    public struct InFlight: Sendable, Equatable {+        public let hostname: String+        public let origin: Origin+        /// Set when the corpus this attempt was computed against went away+        /// under it (`invalidate`, `reconcile`, memory warning). The attempt is+        /// still running — the coordinator owns the `Task` — but whatever it+        /// returns is about a corpus that no longer exists, so `settle` throws+        /// the result away instead of holding it (Reqs 5.5, 5.6).+        public private(set) var voided = false++        public init(hostname: String, origin: Origin, voided: Bool = false) {+            self.hostname = hostname+            self.origin = origin+            self.voided = voided+        }++        fileprivate mutating func void() { voided = true }+    }++    public private(set) var held: [String: RuleSuggestion] = [:]+    public private(set) var attempted: Set<String> = []+    public private(set) var dismissed: [String: Set<Side>] = [:]+    public private(set) var inFlight: InFlight?+    public private(set) var budgetSpent: Duration = .zero+    public private(set) var fingerprints: [String: CorpusFingerprint] = [:]++    /// The sweep that may still start attempts, if any. `resignActive` and+    /// pre-emption clear it: this is the sweep's **stop signal**.+    public private(set) var permittedSweep: Int?+    /// The sweep coroutine that has begun and not yet ended. This is the+    /// **single-instance guard**, and it is deliberately not the same thing as+    /// the stop signal: a stopped sweep is still running until it notices, and+    /// starting a second one under it would put two sweeps in the same loop.+    public private(set) var runningSweep: Int?+    private var sweepGenerations = 0++    public init() {}++    // MARK: - Reads++    public func held(for hostname: String) -> RuleSuggestion? { held[hostname] }++    public func isAttempted(_ hostname: String) -> Bool { attempted.contains(hostname) }++    public func isDismissed(hostname: String, side: Side) -> Bool {+        dismissed[hostname]?.contains(side) ?? false+    }++    /// Exhaustion stops the background sweep only (Q31).+    public var budgetExhausted: Bool { budgetSpent >= RuleSuggestionBounds.runTimeBudget }++    /// Whether an `.open` start is already decided against, on state the ledger+    /// holds. `start` applies it, and the coordinator asks *before* the library+    /// read that a start would otherwise need: an editor opening on a hostname+    /// nothing can be done for must not pay for a candidate row to be told so.+    public func refusesOpen(hostname: String) -> Bool {+        openRefusal(hostname: hostname) != nil+    }++    /// The same decision with the reason attached, for the coordinator's log.+    public func openRefusal(hostname: String) -> RefusalReason? {+        // A start for the hostname already running attaches instead (Q16).+        if let inFlight, inFlight.hostname == hostname, !inFlight.voided { return nil }+        // Attempted this run: no second automatic attempt (Req 5.7).+        if attempted.contains(hostname) { return .attempted }+        // An open never pre-empts the reader's own request, so behind one there+        // is nothing left for it to do (Q55).+        if let inFlight, inFlight.origin == .request {+            return .inFlight(hostname: inFlight.hostname)+        }+        // Both sides rejected this run: the automatic path would apply neither+        // (Req 2.8), so the attempt would be a model call spent on nothing.+        return isFullyDismissed(hostname) ? .bothSidesDismissed : nil+    }++    /// Whether the reader has rejected everything an attempt could offer this+    /// hostname. Such a hostname is worth no model call on any automatic path:+    /// `invalidate` keeps dismissals by design (Q18), so a corpus change must+    /// not put it back in front of the sweep.+    public func isFullyDismissed(_ hostname: String) -> Bool {+        Side.allCases.allSatisfy { isDismissed(hostname: hostname, side: $0) }+    }++    /// The hostnames `reconcile` has to ask the library about.+    public var trackedHostnames: Set<String> {+        var tracked = Set(held.keys)+        tracked.formUnion(attempted)+        if let inFlight { tracked.insert(inFlight.hostname) }+        return tracked+    }++    // MARK: - Sweep++    /// Whether the sweep may still start attempts. Read for its own sake; a+    /// sweep asks `isSweeping(generation:)` about *itself*, because a sweep+    /// still running after its stop signal must not be revived by a later one.+    public var sweepActive: Bool { permittedSweep != nil }++    /// The token for this sweep, or nil when a sweep coroutine is still+    /// running: one activation sweep at a time (Req 5.1).+    ///+    /// The token exists because the two things `sweepActive` used to mean have+    /// to be told apart. A sweep that `resignActive` stopped keeps running+    /// until its awaited attempt returns; without a generation, its `endSweep`+    /// would switch off whichever sweep had started in the meantime, and that+    /// sweep would abort having attempted nothing.+    @discardableResult+    public mutating func beginSweep() -> Int? {+        guard runningSweep == nil else { return nil }+        sweepGenerations += 1+        runningSweep = sweepGenerations+        permittedSweep = sweepGenerations+        return sweepGenerations+    }++    /// Whether *this* sweep may still start attempts (Req 5.3).+    public func isSweeping(generation: Int) -> Bool { permittedSweep == generation }++    /// Ends the sweep the token identifies. A token from a sweep that has+    /// already ended is stale and ends nothing.+    public mutating func endSweep(generation: Int) {+        guard runningSweep == generation else { return }+        runningSweep = nil+        if permittedSweep == generation { permittedSweep = nil }+    }++    /// The stop signal on its own: the running coroutine is left alone, and+    /// only it can end the sweep.+    private mutating func stopSweep() { permittedSweep = nil }++    // MARK: - Attempts++    /// The fingerprint is required: it is what `reconcile` compares the next+    /// candidate row against, and a missing one would read as "nothing known"+    /// and invalidate the hostname on the very next reconcile.+    public mutating func start(hostname: String, origin: Origin,+                               fingerprint: CorpusFingerprint,+                               environment: RuleSuggestionEnvironment) -> AttemptStart {+        // Whatever asked for it, the work is already being done (Q16) — unless+        // it has been voided, in which case its answer is already worthless and+        // attaching would hand the caller nothing.+        if let inFlight, inFlight.hostname == hostname, !inFlight.voided { return .attach }++        switch origin {+        case .background:+            // In the order the sweep would hit them, each carrying the reason+            // the caller logs.+            if attempted.contains(hostname) { return .refuse(.attempted) }+            // Req 2.8 for the automatic path: a hostname the reader rejected on+            // both sides is worth neither a sweep slot nor the budget, and a+            // corpus change must not hand it one back (Q18).+            if isFullyDismissed(hostname) { return .refuse(.bothSidesDismissed) }+            if budgetExhausted { return .refuse(.budgetExhausted) }+            if let inFlight { return .refuse(.inFlight(hostname: inFlight.hostname)) }+            if !environment.isActive { return .refuse(.notActive) }+            if environment.isLowPowerMode { return .refuse(.lowPower) }+            if environment.isThermallyConstrained { return .refuse(.thermallyConstrained) }++        case .open:+            // Everything an open can be refused for without the library: a+            // hostname attempted this run, both sides rejected, or the reader's+            // own request holding the floor (Reqs 5.7, 2.8, Q55).+            if let reason = openRefusal(hostname: hostname) { return .refuse(reason) }+            if let inFlight {+                // The reader's hostname must not wait behind an unrelated job+                // (Q23); a request outranks an open, and `refusesOpen` has+                // already turned that case away.+                stopSweep()+                return .preempt(hostname: inFlight.hostname)+            }++        case .request:+            // Req 6.6: regardless of prior attempts, and ahead of anything else.+            if let inFlight {+                stopSweep()+                return .preempt(hostname: inFlight.hostname)+            }+        }++        inFlight = InFlight(hostname: hostname, origin: origin)+        fingerprints[hostname] = fingerprint+        return .start+    }++    public mutating func settle(hostname: String, _ settlement: AttemptSettlement,+                                modelPhase: Duration) {+        // Every attempt spends what it spent, including the pre-empted ones+        // (Req 5.2).+        budgetSpent += modelPhase+        let wasVoided = inFlight?.hostname == hostname && inFlight?.voided == true+        if inFlight?.hostname == hostname { inFlight = nil }++        // A voided attempt was computed against a corpus that has since+        // changed, so its answer is discarded whatever it says, and the+        // hostname stays attemptable — exactly a `.cancelled` ending+        // (Reqs 5.4, 5.5, 5.6).+        guard !wasVoided else { return }++        switch settlement {+        case .suggestion(let suggestion):+            // A suggestion always belongs to the hostname it was asked about.+            // A mismatch means the coordinator crossed two attempts, and+            // holding it anyway would file one site's rule under another and+            // offer it to the reader; that is worth a crash, not a silent+            // wrong answer.+            precondition(suggestion.hostname == hostname,+                         "settled \(suggestion.hostname) against \(hostname)")+            attempted.insert(hostname)+            // Only a settled attempt with at least one side is held; an empty+            // suggestion is a settled attempt with nothing to show.+            if !suggestion.isEmpty { held[hostname] = suggestion }+        case .noSuggestion, .timedOut:+            attempted.insert(hostname)+        case .cancelled:+            // The app's own pre-emption must not burn a sweep slot (Q28).+            break+        }+    }++    // MARK: - Dismissal++    public mutating func dismiss(hostname: String, side: Side) {+        dismissed[hostname, default: []].insert(side)+    }++    public mutating func clearDismissal(hostname: String, side: Side) {+        dismissed[hostname]?.remove(side)+        if dismissed[hostname]?.isEmpty == true { dismissed[hostname] = nil }+    }++    // MARK: - Invalidation++    /// Drops everything computed for a hostname, but never the reader's+    /// rejection of a side (Req 2.8, Q18).+    ///+    /// Returns whether an attempt for that hostname is in flight and must be+    /// cancelled by the caller. That attempt is voided here and now, so a+    /// result that lands before the cancellation takes effect cannot re-hold a+    /// rule derived from the corpus that just changed (Req 5.5).+    @discardableResult+    public mutating func invalidate(hostname: String) -> Bool {+        held[hostname] = nil+        attempted.remove(hostname)+        fingerprints[hostname] = nil+        guard inFlight?.hostname == hostname else { return false }+        inFlight?.void()+        return true+    }++    /// Compares fresh candidate rows to what each tracked hostname was computed+    /// against and invalidates the ones that moved. A tracked hostname absent+    /// from `candidates` has lost its Site and is invalidated too.+    ///+    /// Returns the invalidated hostnames; a returned hostname that is in flight+    /// still has to be cancelled by the caller.+    @discardableResult+    public mutating func reconcile(against candidates: [String: CorpusFingerprint]) -> Set<String> {+        var invalidated: Set<String> = []+        for hostname in trackedHostnames where candidates[hostname] != fingerprints[hostname] {+            invalidate(hostname: hostname)+            invalidated.insert(hostname)+        }+        return invalidated+    }++    /// Req 5.6: the run's cheap state goes, dismissals stay. Returns the+    /// in-flight hostname the caller must cancel, if any. That attempt is+    /// voided too — its fingerprint has just been dropped, so its result can no+    /// longer be reconciled against anything.+    public mutating func memoryWarning() -> String? {+        held.removeAll()+        attempted.removeAll()+        fingerprints.removeAll()+        inFlight?.void()+        return inFlight?.hostname+    }++    /// Req 5.3: the sweep stops, and its attempt with it. On-open and+    /// on-request work is the reader's and continues.+    public mutating func resignActive() -> String? {+        stopSweep()+        guard let inFlight, inFlight.origin == .background else { return nil }+        return inFlight.hostname+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift Added +131 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swiftnew file mode 100644index 0000000..e49902c--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift@@ -0,0 +1,131 @@+import AsterismCore+import Foundation++/// Which half of a composed teaching rule a suggestion, marker or dismissal+/// applies to. The two sides are offered, applied and rejected independently+/// (Req 1.3, Q7).+public enum Side: String, Sendable, Hashable, CaseIterable {+    case title+    case url+}++/// Why an attempt was started. It decides what the attempt may pre-empt, what+/// gates it is subject to, and whether a prior attempt for the hostname blocks+/// it (Reqs 5.1, 5.7, 6.6).+public enum Origin: String, Sendable, Hashable, CaseIterable {+    /// The activation sweep.+    case background+    /// The composed teaching editor opening on a hostname with nothing held.+    case open+    /// The reader using the Suggest action.+    case request+}++/// How this feature spells a failure in its logs, in one place.+///+/// Every failure here settles the attempt with `nil` and tells the reader+/// nothing (Req 4.2), so the log line is the only record — and the type is+/// most of what makes it readable, because `String(describing:)` alone reduces+/// a framework error to an opaque case name.+public enum SuggestionFailure {+    public static func describe(_ error: any Error) -> String {+        "\(type(of: error)): \(String(describing: error))"+    }+}++/// Thrown by the attempt wrapper when `RuleSuggestionBounds.attemptTimeout`+/// elapses. Distinct from `CancellationError` on purpose: a timeout marks the+/// hostname attempted, a system cancellation does not (Q45, Q28).+public struct AttemptTimeout: Error, Sendable, Equatable {+    /// What the abandoned attempt spent, measured from the model request. The+    /// budget is charged for it (Req 5.2) and only the attempt itself was+    /// holding a clock, so the timeout carries the number out rather than+    /// leaving the coordinator to assume the bound was reached exactly.+    ///+    /// No default: a timeout charges the budget, and a construction that+    /// silently charged nothing would be a leak the compiler could have caught.+    public var modelPhase: Duration++    public init(modelPhase: Duration) {+        self.modelPhase = modelPhase+    }+}++/// Whether the on-device model can be asked for a proposal. The reason is+/// carried for logging only — Req 4.1 forbids showing it to the reader.+public enum ModelAvailability: Sendable, Equatable {+    case available+    case unavailable(reason: String)++    public var isAvailable: Bool {+        switch self {+        case .available: true+        case .unavailable: false+        }+    }+}++/// One capture as the model sees it: the exact title and the exact raw URL.+public struct SuggestionExample: Sendable, Equatable {+    public var title: String+    public var rawURL: String++    public init(title: String, rawURL: String) {+        self.title = title+        self.rawURL = rawURL+    }+}++/// A verified title rule, in the shape the editor seeds a stored rule from.+public struct TitleRuleSuggestion: Sendable, Equatable {+    public var definition: PatternDefinition+    public var trimPrefix: String?+    public var trimSuffix: String?++    public init(definition: PatternDefinition, trimPrefix: String? = nil, trimSuffix: String? = nil) {+        self.definition = definition+        self.trimPrefix = trimPrefix+        self.trimSuffix = trimSuffix+    }+}++/// The held artefact: a *rule* per side, not the spans it was derived from.+///+/// A background computation cannot know which capture the reader will open the+/// editor from, and spans only mean something against one capture — so what is+/// held has to be seedable onto any of them (Q14).+public struct RuleSuggestion: Sendable, Equatable {+    public var hostname: String+    public var title: TitleRuleSuggestion?+    public var url: URLRuleDefinition?++    public init(hostname: String, title: TitleRuleSuggestion? = nil, url: URLRuleDefinition? = nil) {+        self.hostname = hostname+        self.title = title+        self.url = url+    }++    /// Whether either side survived verification. A suggestion with neither is+    /// never held.+    public var isEmpty: Bool { title == nil && url == nil }+}++/// What a held suggestion was computed against. `reconcile` compares a fresh+/// candidate row to this and invalidates the hostname on any difference, which+/// is how Req 5.5 covers captures, Site mode and stored rules with one check.+public struct CorpusFingerprint: Sendable, Equatable {+    public var siteMode: SiteMode+    public var entryCount: Int+    public var latestCaptureAt: Date?+    public var titleRuleVersion: Int?+    public var urlRuleVersion: Int?++    public init(siteMode: SiteMode, entryCount: Int, latestCaptureAt: Date?,+                titleRuleVersion: Int? = nil, urlRuleVersion: Int? = nil) {+        self.siteMode = siteMode+        self.entryCount = entryCount+        self.latestCaptureAt = latestCaptureAt+        self.titleRuleVersion = titleRuleVersion+        self.urlRuleVersion = urlRuleVersion+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/SuggestionCorpus.swift Added +62 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/SuggestionCorpus.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/SuggestionCorpus.swiftnew file mode 100644index 0000000..ff3d2ec--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/SuggestionCorpus.swift@@ -0,0 +1,62 @@+import AsterismCore+import Foundation++/// The bounded, deterministically chosen set of a hostname's captures the model+/// is shown (Req 3.7, Q24).+///+/// The **anchor** is the newest capture and the only one the model answers+/// about; the context entries exist to show which parts of a title and a URL+/// change between chapters.+public struct SuggestionCorpus: Sendable, Equatable {+    public var hostname: String+    public var anchor: SuggestionExample+    /// At most `RuleSuggestionBounds.captureSampleCount - 1` entries, newest+    /// first.+    public var context: [SuggestionExample]++    public init(hostname: String, anchor: SuggestionExample, context: [SuggestionExample] = []) {+        self.hostname = hostname+        self.anchor = anchor+        self.context = context+    }++    /// Every example the model is shown, anchor first.+    public var examples: [SuggestionExample] { [anchor] + context }++    /// The hostname's captures, newest first, capped at+    /// `RuleSuggestionBounds.captureSampleCount`.+    ///+    /// Selection is deterministic — captures sharing a timestamp are ordered by+    /// id — so the same corpus yields the same suggestion twice (Q24). Nil when+    /// the hostname has no captures.+    public static func make(from entries: [ComposedEntryBasis], hostname: String) -> SuggestionCorpus? {+        let ordered = entries.sorted { left, right in+            if left.firstCapturedAt != right.firstCapturedAt {+                return left.firstCapturedAt > right.firstCapturedAt+            }+            return left.id.uuidString < right.id.uuidString+        }+        guard let anchor = ordered.first else { return nil }++        let context = ordered.dropFirst().prefix(RuleSuggestionBounds.captureSampleCount - 1)+        return SuggestionCorpus(hostname: hostname,+                                anchor: example(anchor),+                                context: context.map(example))+    }++    /// The same corpus with half the context, oldest dropped first.+    ///+    /// This is the response to `exceededContextWindowSize`: Req 3.7 shrinks the+    /// input rather than skipping the hostname. Nil once only the anchor is+    /// left — that is the floor, and there is nothing further to try.+    public func halved() -> SuggestionCorpus? {+        guard !context.isEmpty else { return nil }+        return SuggestionCorpus(hostname: hostname,+                                anchor: anchor,+                                context: Array(context.prefix(context.count / 2)))+    }++    private static func example(_ entry: ComposedEntryBasis) -> SuggestionExample {+        SuggestionExample(title: entry.captureTitle, rawURL: entry.rawURLString)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swiftindex 69533f5..cb05af2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ComposedTeachingRepositoryTests.swift@@ -474,9 +474,9 @@ struct ComposedRepoFixture {      func freshContext() -> ModelContext { ModelContext(container) } -    func seed(_ mutate: (ModelContext) -> Void) throws {+    func seed(_ mutate: (ModelContext) throws -> Void) throws {         let context = ModelContext(container)-        mutate(context)+        try mutate(context)         try context.save()     } 
Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift Added +259 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swiftnew file mode 100644index 0000000..f611184--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift@@ -0,0 +1,259 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The rule-suggestion candidate read (Reqs 1.6, 5.1, 5.5): one row per+/// hostname describing what the sweep needs to pick a hostname and what+/// `reconcile` needs to notice a change.+@Suite("Rule suggestion candidates", .serialized)+struct RuleSuggestionCandidatesTests {++    // MARK: - Seeding helpers++    @discardableResult+    private func insertSite(+        _ context: ModelContext, hostname: String, mode: SiteMode+    ) -> Site {+        let site = Site(hostname: hostname)+        site.mode = mode+        context.insert(site)+        return site+    }++    private func insertEntry(+        _ context: ModelContext, hostname: String, capturedAt: Date, suffix: String+    ) {+        let url = "https://\(hostname)/read/\(suffix)"+        let entry = Entry(+            id: UUID(), captureTitle: "Chapter \(suffix)", captureTitleSource: .host,+            rawURLString: url, hostname: hostname, entryIdentityKey: url,+            timestamp: capturedAt)+        entry.conservativeIdentityKey = url+        entry.identityKeyVersion = 1+        entry.identityBasis = .conservative+        context.insert(entry)+    }++    private func insertTitlePattern(+        _ context: ModelContext, site: Site, version: Int, isActive: Bool+    ) throws {+        let pattern = try TitlePattern(+            version: version, isActive: isActive,+            createdAt: Date(timeIntervalSince1970: 1_000), definition: .wholeTitle, site: site)+        context.insert(pattern)+    }++    private func insertURLRule(+        _ context: ModelContext, site: Site, version: Int, isCurrent: Bool+    ) throws {+        let rule = try URLRulePattern(+            version: version, isCurrent: isCurrent,+            createdAt: Date(timeIntervalSince1970: 1_000), origin: .readerTaught,+            definition: .sequence(locator: .query(name: ExactScalarString("chapter"))),+            site: site)+        context.insert(rule)+    }++    private func candidate(+        _ rows: [RuleSuggestionCandidate], _ hostname: String+    ) throws -> RuleSuggestionCandidate {+        try #require(rows.first { $0.hostname == hostname })+    }++    // MARK: - Selection++    @Test("A nil hostname set returns every hostname, sorted")+    func nilHostnamesReturnsEveryWinner() async throws {+        let fixture = try ComposedRepoFixture()+        // Seeded backwards: the rows come out of a dictionary of winners, so+        // only an explicit sort can put them back in hostname order.+        try fixture.seed { context in+            insertSite(context, hostname: "c.example", mode: .articles)+            insertSite(context, hostname: "b.example", mode: .untaught)+            insertSite(context, hostname: "a.example", mode: .untaught)+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil)++        #expect(rows.map(\.hostname) == ["a.example", "b.example", "c.example"])+    }++    @Test("A hostname subset returns only those hostnames")+    func subsetReturnsOnlyRequestedHostnames() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            insertSite(context, hostname: "c.example", mode: .untaught)+            insertSite(context, hostname: "b.example", mode: .untaught)+            insertSite(context, hostname: "a.example", mode: .untaught)+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(+            hostnames: ["c.example", "a.example", "never.example"])++        #expect(rows.map(\.hostname) == ["a.example", "c.example"])+    }++    @Test("An empty hostname set returns no rows")+    func emptySubsetReturnsNothing() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            insertSite(context, hostname: "a.example", mode: .untaught)+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: [])++        #expect(rows.isEmpty)+    }++    // MARK: - Fields++    @Test("Each row carries its Site's mode")+    func siteModeIsReported() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            insertSite(context, hostname: "untaught.example", mode: .untaught)+            let taught = insertSite(context, hostname: "taught.example", mode: .taught)+            try insertTitlePattern(context, site: taught, version: 1, isActive: true)+            insertSite(context, hostname: "articles.example", mode: .articles)+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil)++        #expect(try candidate(rows, "untaught.example").siteMode == .untaught)+        #expect(try candidate(rows, "taught.example").siteMode == .taught)+        #expect(try candidate(rows, "articles.example").siteMode == .articles)+    }++    @Test("Rule versions come from the active title pattern and the current URL rule")+    func ruleVersionsComeFromTheLiveRules() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            let taught = insertSite(context, hostname: "taught.example", mode: .taught)+            // Superseded records must not be read: only the active/current ones.+            try insertTitlePattern(context, site: taught, version: 1, isActive: false)+            try insertTitlePattern(context, site: taught, version: 4, isActive: true)+            try insertURLRule(context, site: taught, version: 1, isCurrent: false)+            try insertURLRule(context, site: taught, version: 2, isCurrent: true)++            // A title rule and no URL rule at all.+            let titleOnly = insertSite(context, hostname: "title.example", mode: .taught)+            try insertTitlePattern(context, site: titleOnly, version: 7, isActive: true)++            insertSite(context, hostname: "untaught.example", mode: .untaught)+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil)++        let taught = try candidate(rows, "taught.example")+        #expect(taught.titleRuleVersion == 4)+        #expect(taught.urlRuleVersion == 2)++        let titleOnly = try candidate(rows, "title.example")+        #expect(titleOnly.titleRuleVersion == 7)+        #expect(titleOnly.urlRuleVersion == nil)++        let untaught = try candidate(rows, "untaught.example")+        #expect(untaught.titleRuleVersion == nil)+        #expect(untaught.urlRuleVersion == nil)+    }++    @Test("Entry count and latest capture describe the hostname's captures")+    func entryCountAndLatestCapture() async throws {+        let fixture = try ComposedRepoFixture()+        let newest = Date(timeIntervalSince1970: 1_800_000_300)+        try fixture.seed { context in+            insertSite(context, hostname: "a.example", mode: .untaught)+            insertSite(context, hostname: "b.example", mode: .untaught)+            insertEntry(+                context, hostname: "a.example",+                capturedAt: Date(timeIntervalSince1970: 1_800_000_100), suffix: "1")+            insertEntry(context, hostname: "a.example", capturedAt: newest, suffix: "2")+            insertEntry(+                context, hostname: "a.example",+                capturedAt: Date(timeIntervalSince1970: 1_800_000_200), suffix: "3")+            insertEntry(+                context, hostname: "b.example",+                capturedAt: Date(timeIntervalSince1970: 1_800_000_900), suffix: "1")+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(+            hostnames: ["a.example"])++        let row = try candidate(rows, "a.example")+        #expect(row.entryCount == 3)+        #expect(row.latestCaptureAt == newest)+    }++    @Test("A Site with no captures reports zero entries and no latest capture")+    func siteWithoutEntries() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            insertSite(context, hostname: "empty.example", mode: .untaught)+            insertSite(context, hostname: "other.example", mode: .untaught)+            insertEntry(+                context, hostname: "other.example",+                capturedAt: Date(timeIntervalSince1970: 1_800_000_100), suffix: "1")+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil)++        let row = try candidate(rows, "empty.example")+        #expect(row.entryCount == 0)+        #expect(row.latestCaptureAt == nil)+    }++    // MARK: - Fail-closed++    @Test("A Site whose mode raw is outside the closed set has no row at all")+    func unknownSiteModeRawIsOmitted() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            let stranger = insertSite(context, hostname: "stranger.example", mode: .untaught)+            // A raw value no writer produces: coercing it to `.untaught` would+            // make the hostname auto-eligible and propose rules for a state+            // nothing here understands, so the row is dropped instead (Q15).+            stranger.modeRaw = "teleported"+            insertSite(context, hostname: "known.example", mode: .untaught)+            insertEntry(+                context, hostname: "stranger.example",+                capturedAt: Date(timeIntervalSince1970: 1_800_000_100), suffix: "1")+        }++        let all = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil)+        #expect(all.map(\.hostname) == ["known.example"])++        // Named directly, it is still absent — which is what makes `reconcile`+        // read the hostname as changed rather than unchanged.+        let named = try await fixture.repository.ruleSuggestionCandidates(+            hostnames: ["stranger.example", "known.example"])+        #expect(named.map(\.hostname) == ["known.example"])+    }++    // MARK: - Duplicate Site rows++    @Test("Two Site rows for one hostname yield the resolution winner, once")+    func duplicateSiteRowsResolveToTheWinner() async throws {+        let fixture = try ComposedRepoFixture()+        try fixture.seed { context in+            // Step 1 of `SiteResolutionOrder`: the row with an active title+            // pattern wins over the untaught duplicate.+            insertSite(context, hostname: "dup.example", mode: .untaught)+            let taught = insertSite(context, hostname: "dup.example", mode: .taught)+            try insertTitlePattern(context, site: taught, version: 5, isActive: true)+            insertEntry(+                context, hostname: "dup.example",+                capturedAt: Date(timeIntervalSince1970: 1_800_000_100), suffix: "1")+        }++        let rows = try await fixture.repository.ruleSuggestionCandidates(hostnames: nil)++        #expect(rows.count == 1)+        let row = try candidate(rows, "dup.example")+        #expect(row.siteMode == .taught)+        #expect(row.titleRuleVersion == 5)+        // The count is per hostname, so a duplicate row does not double it.+        #expect(row.entryCount == 1)+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationRuleSuggestionModelClientTests.swift Added +147 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationRuleSuggestionModelClientTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationRuleSuggestionModelClientTests.swiftnew file mode 100644index 0000000..0ea511c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationRuleSuggestionModelClientTests.swift@@ -0,0 +1,147 @@+import Foundation+import FoundationModels+import Testing++@testable import AsterismIntelligence++@Suite("FoundationRuleSuggestionModelClient")+struct FoundationRuleSuggestionModelClientTests {+    static let corpus = SuggestionCorpus(+        hostname: "example.net",+        anchor: SuggestionExample(title: "Sylver Seeker - Chapter 12: The Long Road",+                                  rawURL: "https://example.net/novel/sylver-seeker/chapter-12"),+        context: [+            SuggestionExample(title: "Sylver Seeker - Chapter 11: Ashes",+                              rawURL: "https://example.net/novel/sylver-seeker/chapter-11"),+        ])++    // MARK: - Availability mapping (Req 4.1)++    @Test("Availability maps to the feature's own gate, reason included for logging")+    func availabilityMapping() {+        #expect(FoundationRuleSuggestionModelClient.availability(from: .available) == .available)++        for reason: SystemLanguageModel.Availability.UnavailableReason in+            [.deviceNotEligible, .appleIntelligenceNotEnabled, .modelNotReady] {+            let mapped = FoundationRuleSuggestionModelClient.availability(from: .unavailable(reason))+            #expect(!mapped.isAvailable)+            if case .unavailable(let text) = mapped {+                #expect(!text.isEmpty)+            }+        }++        #expect(FoundationRuleSuggestionModelClient.availability(from: .unavailable(.modelNotReady))+            != FoundationRuleSuggestionModelClient.availability(from: .unavailable(.deviceNotEligible)))+    }++    // MARK: - Instructions (Decision 1)++    @Test("The instructions demand verbatim copying and carry three worked examples")+    func instructions() {+        let instructions = FoundationRuleSuggestionModelClient.instructions++        #expect(instructions.contains("exactly as it appears"))+        #expect(instructions.contains("Example A"))+        #expect(instructions.contains("Example B"))+        // Example C is the opaque-address case: chapter in the title, nothing in the URL.+        #expect(instructions.contains("Example C"))+        #expect(!instructions.contains("Example D"))+        #expect(instructions.contains("domain name is never urlWorkIdentity"))+        // A bare number or decimal is a chapter (1.00 - The Wandering Inn).+        #expect(instructions.contains("1.00"))+        // Every field of the proposal is named, in the order it is declared.+        let fields = ["workName", "chapterText", "urlWorkIdentity", "urlSequenceText"]+        var searchStart = instructions.startIndex+        for field in fields {+            let found = instructions.range(of: field, range: searchStart ..< instructions.endIndex)+            #expect(found != nil, "\(field) missing or out of order in the instructions")+            searchStart = found?.upperBound ?? searchStart+        }+        // Empty means "none" (Q39), and the model is told so.+        #expect(instructions.contains("empty"))+    }++    // MARK: - Prompt (Req 3.7)++    @Test("The prompt numbers the examples with the anchor first")+    func promptNumbersAnchorFirst() {+        let prompt = FoundationRuleSuggestionModelClient.prompt(for: Self.corpus)++        #expect(prompt.contains("1. title: Sylver Seeker - Chapter 12: The Long Road"))+        #expect(prompt.contains("   url: https://example.net/novel/sylver-seeker/chapter-12"))+        #expect(prompt.contains("2. title: Sylver Seeker - Chapter 11: Ashes"))+        if let anchorPosition = prompt.range(of: "Chapter 12")?.lowerBound,+           let contextPosition = prompt.range(of: "Chapter 11")?.lowerBound {+            #expect(anchorPosition < contextPosition)+        } else {+            Issue.record("both examples must appear in the prompt")+        }+        #expect(prompt.contains("example.net"))+    }++    @Test("A one-capture corpus produces a single numbered example")+    func promptWithOnlyTheAnchor() {+        let corpus = SuggestionCorpus(hostname: "example.net", anchor: Self.corpus.anchor)++        let prompt = FoundationRuleSuggestionModelClient.prompt(for: corpus)++        #expect(prompt.contains("1. title: "))+        #expect(!prompt.contains("2. title: "))+    }++    // MARK: - Generation options (Decision 1: reproducible for a given corpus)++    @Test("Sampling is greedy so the same corpus yields the same proposal")+    func greedyOptions() {+        #expect(FoundationRuleSuggestionModelClient.generationOptions+            == GenerationOptions(sampling: .greedy))+    }++    // MARK: - Context overflow (Req 3.7)++    @Test("A context-window overflow is recognisable by type, so the corpus can be halved")+    func contextOverflowIsTyped() {+        let client = FoundationRuleSuggestionModelClient()+        let overflow = LanguageModelSession.GenerationError+            .exceededContextWindowSize(.init(debugDescription: "too long"))+        let other = LanguageModelSession.GenerationError+            .guardrailViolation(.init(debugDescription: "nope"))++        #expect(client.isContextWindowOverflow(overflow))+        #expect(!client.isContextWindowOverflow(other))+        #expect(!client.isContextWindowOverflow(CancellationError()))+        #expect(!client.isContextWindowOverflow(AttemptTimeout(modelPhase: .zero)))+    }++    @Test("The overflow question is answerable through the protocol, without naming the client")+    func contextOverflowIsAskedThroughTheSeam() {+        // What the suggester will hold: an existential with no idea which+        // client it is or that `FoundationModels` exists.+        let client: any RuleSuggestionModelClient = FoundationRuleSuggestionModelClient()+        let overflow = LanguageModelSession.GenerationError+            .exceededContextWindowSize(.init(debugDescription: "too long"))++        #expect(client.isContextWindowOverflow(overflow))+        #expect(!client.isContextWindowOverflow(AttemptTimeout(modelPhase: .zero)))+    }++    // MARK: - One live call++    @Test("A live model call returns a decodable RuleProposal for a two-example corpus")+    func liveCall() async throws {+        let client = FoundationRuleSuggestionModelClient()+        try await withKnownIssue("The on-device model is unavailable on this host") {+            try #require(SystemLanguageModel.default.isAvailable)+            #expect(client.availability() == .available)++            let proposal = try await client.propose(Self.corpus)++            // What the model actually picks is its business — Req 3 validates+            // it downstream. What this asserts is that the call round-trips+            // into the structure at all.+            #expect(!proposal.workName.isEmpty)+        } when: {+            !SystemLanguageModel.default.isAvailable+        }+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/ProposalLocatorTests.swift Added +199 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/ProposalLocatorTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/ProposalLocatorTests.swiftnew file mode 100644index 0000000..28372ac--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/ProposalLocatorTests.swift@@ -0,0 +1,199 @@+import Foundation+import Testing++@testable import AsterismIntelligence++@Suite("ProposalLocator")+struct ProposalLocatorTests {+    // MARK: - The four outcomes (Req 3.1, Decision 1)++    @Test("A unique occurrence yields its character range")+    func uniqueOccurrence() {+        let source = "Sylver Seeker - Chapter 12"+        #expect(ProposalLocator.locate("Sylver Seeker", in: source) == 0 ..< 13)+        #expect(ProposalLocator.locate("Chapter 12", in: source) == 16 ..< 26)+        #expect(ProposalLocator.locate(source, in: source) == 0 ..< source.count)+    }++    @Test("Text that does not appear yields nil")+    func absent() {+        #expect(ProposalLocator.locate("Chapter 13", in: "Sylver Seeker - Chapter 12") == nil)+        // The locator is exact by design: case and whitespace drift are misses.+        #expect(ProposalLocator.locate("sylver seeker", in: "Sylver Seeker - Chapter 12") == nil)+        #expect(ProposalLocator.locate("Sylver  Seeker", in: "Sylver Seeker - Chapter 12") == nil)+    }++    @Test("Text that appears twice is ambiguous and yields nil")+    func duplicate() {+        #expect(ProposalLocator.locate("12", in: "Chapter 12 of 12") == nil)+        // Overlapping repeats count as two occurrences: neither span is the one.+        #expect(ProposalLocator.locate("aa", in: "aaa") == nil)+    }++    @Test("Empty text never locates, in any source")+    func empty() {+        #expect(ProposalLocator.locate("", in: "Sylver Seeker") == nil)+        #expect(ProposalLocator.locate("", in: "") == nil)+        #expect(ProposalLocator.locate("anything", in: "") == nil)+    }++    // MARK: - The placement entry point (Req 3.1)++    @Test("Placement tells absence and ambiguity apart, which locate() collapses")+    func placementSeparatesAbsentFromAmbiguous() {+        let source = "Chapter 12 of 12"+        #expect(ProposalLocator.place("Chapter", in: source) == .unique(0 ..< 7))+        #expect(ProposalLocator.place("13", in: source) == .absent)+        #expect(ProposalLocator.place("12", in: source) == .ambiguous)+        // Both non-unique outcomes are the same nil to a caller with one source.+        #expect(ProposalLocator.locate("13", in: source) == nil)+        #expect(ProposalLocator.locate("12", in: source) == nil)+    }++    @Test("Text longer than the source, and empty text, are absent rather than ambiguous")+    func placementEdges() {+        #expect(ProposalLocator.place("Sylver Seeker", in: "Sylver") == .absent)+        #expect(ProposalLocator.place("", in: "Sylver Seeker") == .absent)+        #expect(ProposalLocator.place("", in: "") == .absent)+        #expect(ProposalLocator.place("anything", in: "") == .absent)+    }++    @Test("Overlapping repeats are ambiguous, not two placements")+    func placementOverlappingRepeats() {+        #expect(ProposalLocator.place("aa", in: "aaa") == .ambiguous)+    }++    @Test("locate() is place() with the non-unique outcomes collapsed",+          arguments: ProposalLocatorPropertyCase.all)+    func locateAgreesWithPlace(_ testCase: ProposalLocatorPropertyCase) {+        switch ProposalLocator.place(testCase.slice, in: testCase.source) {+        case .unique(let range):+            #expect(ProposalLocator.locate(testCase.slice, in: testCase.source) == range)+        case .absent, .ambiguous:+            #expect(ProposalLocator.locate(testCase.slice, in: testCase.source) == nil)+        }+    }++    // MARK: - Offsets are over Characters, matching TitleSegment.range++    @Test("Offsets count Characters, not scalars or UTF-8 bytes")+    func combiningMarksCountAsOneCharacter() {+        // "é" as e + U+0301: one Character, two scalars, three UTF-8 bytes.+        let source = "Cafe\u{0301} Chapter 3"+        #expect(source.count == 14)+        #expect(ProposalLocator.locate("Chapter 3", in: source) == 5 ..< 14)+        #expect(ProposalLocator.locate("Cafe\u{0301}", in: source) == 0 ..< 4)+    }++    @Test("A precomposed needle matches a decomposed source, as Character equality does")+    func canonicalEquivalence() {+        let source = "Cafe\u{0301} Chapter 3"+        #expect(ProposalLocator.locate("Caf\u{00E9}", in: source) == 0 ..< 4)+    }++    @Test("Emoji built from several scalars are single Characters")+    func emojiSequence() {+        let source = "Family 👩‍👩‍👧 Chapter 2"+        #expect(ProposalLocator.locate("👩‍👩‍👧", in: source) == 7 ..< 8)+        #expect(ProposalLocator.locate("Chapter 2", in: source) == 9 ..< 18)+    }++    @Test("CJK titles locate by Character offset")+    func cjk() {+        let source = "転生したらスライムだった件 第12話"+        #expect(ProposalLocator.locate("転生したらスライムだった件", in: source) == 0 ..< 13)+        #expect(ProposalLocator.locate("第12話", in: source) == 14 ..< 18)+        #expect(ProposalLocator.locate("スライム", in: source) == 5 ..< 9)+    }++    @Test("A CJK substring repeated in the source is ambiguous")+    func cjkDuplicate() {+        #expect(ProposalLocator.locate("第", in: "第1話 第2話") == nil)+    }++    // MARK: - Property: random slices round-trip++    @Test("A random slice of a random source locates iff it occurs exactly once",+          arguments: ProposalLocatorPropertyCase.all)+    func randomSliceRoundTrip(_ testCase: ProposalLocatorPropertyCase) {+        let occurrences = testCase.expectedOccurrences+        let located = ProposalLocator.locate(testCase.slice, in: testCase.source)++        if testCase.slice.isEmpty {+            #expect(located == nil)+        } else if occurrences.count == 1 {+            #expect(located == occurrences[0])+            // The located range really does cut the slice back out.+            if let located {+                #expect(String(Array(testCase.source)[located]) == testCase.slice)+            }+        } else {+            #expect(located == nil)+        }+    }+}++/// One generated `(source, slice)` pair, with the expected occurrences computed+/// by an independent implementation (Foundation literal search) so the property+/// test is not the locator agreeing with itself.+struct ProposalLocatorPropertyCase: Sendable, CustomTestStringConvertible {+    let source: String+    let slice: String++    var testDescription: String { "\(source.debugDescription) ⊃ \(slice.debugDescription)" }++    /// Every character-offset range at which `slice` occurs in `source`,+    /// overlapping occurrences included.+    var expectedOccurrences: [Range<Int>] {+        guard !slice.isEmpty else { return [] }+        var found: [Range<Int>] = []+        var searchStart = source.startIndex+        while searchStart < source.endIndex,+              let hit = source.range(of: slice, options: [.literal],+                                     range: searchStart ..< source.endIndex) {+            found.append(source.distance(from: source.startIndex, to: hit.lowerBound)+                ..< source.distance(from: source.startIndex, to: hit.upperBound))+            searchStart = source.index(after: hit.lowerBound)+        }+        return found+    }++    /// Deterministic so a failure is reproducible. The alphabet is deliberately+    /// single-scalar (`.literal` search and Character comparison then agree);+    /// combining marks and grapheme clusters are covered by the cases above.+    static let all: [ProposalLocatorPropertyCase] = {+        let alphabet = Array("aabc 日本-1")+        var rng = SplitMix64(seed: 0x5EED_1234_5EED_1234)+        var cases: [ProposalLocatorPropertyCase] = []+        for _ in 0 ..< 300 {+            let length = Int(rng.next(upperBound: 10)) + 1+            let source = String((0 ..< length).map { _ in alphabet[Int(rng.next(upperBound: UInt64(alphabet.count)))] })+            let characters = Array(source)+            let start = Int(rng.next(upperBound: UInt64(characters.count)))+            let end = start + Int(rng.next(upperBound: UInt64(characters.count - start + 1)))+            cases.append(ProposalLocatorPropertyCase(source: source,+                                                     slice: String(characters[start ..< end])))+        }+        return cases+    }()+}++/// A tiny seeded generator: the property cases must be identical on every run.+struct SplitMix64 {+    private var state: UInt64++    init(seed: UInt64) { state = seed }++    mutating func next() -> UInt64 {+        state &+= 0x9E37_79B9_7F4A_7C15+        var z = state+        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9+        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB+        return z ^ (z >> 31)+    }++    mutating func next(upperBound: UInt64) -> UInt64 {+        precondition(upperBound > 0)+        return next() % upperBound+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift Added +743 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swiftnew file mode 100644index 0000000..bc4cb66--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift@@ -0,0 +1,743 @@+import AsterismCore+import Foundation+import Testing++@testable import AsterismIntelligence++@Suite("RuleSuggestionLedger")+struct RuleSuggestionLedgerTests {+    // MARK: - Fixtures++    static let foreground = RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false,+                                                      thermalState: .nominal)++    static func suggestion(_ hostname: String) -> RuleSuggestion {+        RuleSuggestion(hostname: hostname,+                       title: TitleRuleSuggestion(definition: .wholeTitle),+                       url: nil)+    }++    static func fingerprint(entryCount: Int = 3, titleRuleVersion: Int? = nil) -> CorpusFingerprint {+        CorpusFingerprint(siteMode: .untaught, entryCount: entryCount,+                          latestCaptureAt: Date(timeIntervalSince1970: 1_700_000_000),+                          titleRuleVersion: titleRuleVersion, urlRuleVersion: nil)+    }++    /// A ledger mid-sweep with one attempt running for `hostname`.+    static func running(_ hostname: String, origin: Origin = .background) -> RuleSuggestionLedger {+        var ledger = RuleSuggestionLedger()+        ledger.beginSweep()+        let outcome = ledger.start(hostname: hostname, origin: origin,+                                   fingerprint: fingerprint(), environment: foreground)+        #expect(outcome == .start)+        return ledger+    }++    /// A ledger that has settled one attempt for `hostname`.+    static func settled(_ hostname: String, _ settlement: AttemptSettlement,+                        modelPhase: Duration = .seconds(1)) -> RuleSuggestionLedger {+        var ledger = running(hostname)+        ledger.settle(hostname: hostname, settlement, modelPhase: modelPhase)+        return ledger+    }++    // MARK: - Single flight (Req 5.11)++    @Test("Only one attempt runs at a time")+    func singleFlight() {+        var ledger = Self.running("a.example")++        #expect(ledger.inFlight == RuleSuggestionLedger.InFlight(hostname: "a.example",+                                                                 origin: .background))+        let outcome = ledger.start(hostname: "b.example", origin: .background,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .refuse(.inFlight(hostname: "a.example")))+        #expect(ledger.inFlight?.hostname == "a.example")+    }++    @Test("A start for the hostname already in flight attaches, whatever its origin",+          arguments: Origin.allCases)+    func attachesToTheSameHostname(origin: Origin) {+        var ledger = Self.running("a.example")++        let outcome = ledger.start(hostname: "a.example", origin: origin,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .attach)+        #expect(ledger.inFlight?.origin == .background)+    }++    @Test("An attempted hostname still in flight (re-requested) attaches rather than refusing")+    func attachesEvenWhenAttempted() {+        var ledger = Self.settled("a.example", .noSuggestion)+        _ = ledger.start(hostname: "a.example", origin: .request,+                         fingerprint: Self.fingerprint(),+                         environment: Self.foreground)++        let outcome = ledger.start(hostname: "a.example", origin: .open,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(ledger.isAttempted("a.example"))+        #expect(outcome == .attach)+    }++    // MARK: - Origins (Reqs 5.1, 5.7, 6.6)++    @Test("An on-open attempt is refused for a hostname already attempted this run")+    func openRefusedWhenAttempted() {+        var ledger = Self.settled("a.example", .noSuggestion)++        let outcome = ledger.start(hostname: "a.example", origin: .open,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .refuse(.attempted))+        #expect(ledger.inFlight == nil)+    }++    @Test("An on-request attempt starts for a hostname already attempted this run")+    func requestStartsWhenAttempted() {+        var ledger = Self.settled("a.example", .noSuggestion)++        let outcome = ledger.start(hostname: "a.example", origin: .request,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .start)+        #expect(ledger.inFlight?.origin == .request)+    }++    @Test("An on-open attempt is refused when the reader has rejected both sides")+    func openRefusedWhenBothSidesDismissed() {+        var ledger = RuleSuggestionLedger()+        ledger.dismiss(hostname: "a.example", side: .title)++        // One side left: the attempt is still worth making for it.+        #expect(!ledger.refusesOpen(hostname: "a.example"))+        #expect(ledger.start(hostname: "a.example", origin: .open,+                             fingerprint: Self.fingerprint(),+                             environment: Self.foreground) == .start)++        var both = RuleSuggestionLedger()+        both.dismiss(hostname: "a.example", side: .title)+        both.dismiss(hostname: "a.example", side: .url)++        #expect(both.refusesOpen(hostname: "a.example"))+        #expect(both.start(hostname: "a.example", origin: .open,+                           fingerprint: Self.fingerprint(),+                           environment: Self.foreground) == .refuse(.bothSidesDismissed))+        // The reader can still ask for one (Req 6.5).+        #expect(both.start(hostname: "a.example", origin: .request,+                           fingerprint: Self.fingerprint(),+                           environment: Self.foreground) == .start)+    }++    @Test("A background attempt is refused when the reader has rejected both sides")+    func backgroundRefusedWhenBothSidesDismissed() {+        var ledger = RuleSuggestionLedger()+        ledger.dismiss(hostname: "a.example", side: .title)++        // One side left is still worth the sweep's slot.+        #expect(!ledger.isFullyDismissed("a.example"))+        #expect(ledger.start(hostname: "a.example", origin: .background,+                             fingerprint: Self.fingerprint(),+                             environment: Self.foreground) == .start)++        var both = RuleSuggestionLedger()+        both.dismiss(hostname: "a.example", side: .title)+        both.dismiss(hostname: "a.example", side: .url)++        #expect(both.isFullyDismissed("a.example"))+        #expect(both.start(hostname: "a.example", origin: .background,+                           fingerprint: Self.fingerprint(),+                           environment: Self.foreground) == .refuse(.bothSidesDismissed))+    }++    @Test("Invalidation keeps the rejection, so the sweep still passes the hostname over")+    func invalidationDoesNotReviveAFullyDismissedHostname() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))+        ledger.dismiss(hostname: "a.example", side: .title)+        ledger.dismiss(hostname: "a.example", side: .url)++        // A capture lands: the hold and the attempt go, the rejection stays+        // (Q18) — and with it the refusal, so the corpus change cannot cost a+        // second model call on a hostname the reader has already turned down.+        ledger.invalidate(hostname: "a.example")++        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.isFullyDismissed("a.example"))+        #expect(ledger.start(hostname: "a.example", origin: .background,+                             fingerprint: Self.fingerprint(entryCount: 4),+                             environment: Self.foreground) == .refuse(.bothSidesDismissed))+    }++    @Test("The free refusal agrees with what a start would do")+    func refusesOpenMatchesStart() {+        // Attempted, and behind a request for another hostname: both are+        // answerable without a library read, which is why the coordinator asks.+        #expect(Self.settled("a.example", .noSuggestion).refusesOpen(hostname: "a.example"))+        #expect(Self.running("a.example", origin: .request).refusesOpen(hostname: "b.example"))+        // Behind a background attempt an open pre-empts, so it is not refused;+        // nor is a start for the hostname already running, which attaches.+        #expect(!Self.running("a.example", origin: .background).refusesOpen(hostname: "b.example"))+        #expect(!Self.running("a.example", origin: .request).refusesOpen(hostname: "a.example"))+    }++    @Test("A background attempt is refused for a hostname already attempted this run")+    func backgroundRefusedWhenAttempted() {+        var ledger = Self.settled("a.example", .noSuggestion)++        let outcome = ledger.start(hostname: "a.example", origin: .background,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .refuse(.attempted))+    }++    // MARK: - Pre-emption (Q23, Req 6.6)++    @Test("An editor open pre-empts a background attempt for another hostname")+    func openPreemptsBackground() {+        var ledger = Self.running("a.example", origin: .background)++        let outcome = ledger.start(hostname: "b.example", origin: .open,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .preempt(hostname: "a.example"))+        // The ledger does not start the new attempt itself: the coordinator+        // cancels the running task, settles it, and asks again.+        #expect(ledger.inFlight?.hostname == "a.example")+    }++    @Test("An editor open pre-empts an earlier on-open attempt for another hostname")+    func openPreemptsOpen() {+        var ledger = Self.running("a.example", origin: .open)++        let outcome = ledger.start(hostname: "b.example", origin: .open,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .preempt(hostname: "a.example"))+    }++    @Test("An editor open does not pre-empt a request")+    func openDoesNotPreemptRequest() {+        var ledger = Self.running("a.example", origin: .request)++        let outcome = ledger.start(hostname: "b.example", origin: .open,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .refuse(.inFlight(hostname: "a.example")))+        #expect(ledger.inFlight?.hostname == "a.example")+    }++    @Test("A request pre-empts an attempt of any origin for another hostname",+          arguments: Origin.allCases)+    func requestPreemptsAnything(origin: Origin) {+        var ledger = Self.running("a.example", origin: origin)++        let outcome = ledger.start(hostname: "b.example", origin: .request,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .preempt(hostname: "a.example"))+    }++    @Test("Pre-emption ends the sweep for this activation", arguments: [Origin.open, .request])+    func preemptionEndsTheSweep(origin: Origin) {+        var ledger = Self.running("a.example", origin: .background)+        #expect(ledger.sweepActive)++        _ = ledger.start(hostname: "b.example", origin: origin,+                         fingerprint: Self.fingerprint(),+                         environment: Self.foreground)++        #expect(!ledger.sweepActive)+    }++    @Test("An ordinary start leaves the sweep running")+    func plainStartKeepsTheSweep() {+        var ledger = RuleSuggestionLedger()+        ledger.beginSweep()++        _ = ledger.start(hostname: "a.example", origin: .background,+                         fingerprint: Self.fingerprint(),+                         environment: Self.foreground)++        #expect(ledger.sweepActive)+    }++    @Test("A sweep cannot be begun twice")+    func sweepIsSingleInstance() throws {+        var ledger = RuleSuggestionLedger()++        let begun = ledger.beginSweep()+        let first = try #require(begun)+        let second = ledger.beginSweep()+        ledger.endSweep(generation: first)+        let third = ledger.beginSweep()++        #expect(second == nil)+        #expect(third != nil)+        #expect(third != first)+    }++    @Test("A stopped sweep is still running, and only it can end itself")+    func stopSignalIsNotTheSingleInstanceGuard() throws {+        var ledger = RuleSuggestionLedger()+        let begun = ledger.beginSweep()+        let stopped = try #require(begun)++        _ = ledger.resignActive()++        // Stopped: it starts nothing more. Still running: the coroutine has not+        // returned, so a second sweep may not begin under it.+        #expect(!ledger.isSweeping(generation: stopped))+        #expect(ledger.beginSweep() == nil)++        ledger.endSweep(generation: stopped)+        let begunAgain = ledger.beginSweep()+        let fresh = try #require(begunAgain)+        #expect(ledger.isSweeping(generation: fresh))+    }++    @Test("A token from a sweep that has already ended ends nothing")+    func staleEndSweepIsANoOp() throws {+        var ledger = RuleSuggestionLedger()+        let begun = ledger.beginSweep()+        let first = try #require(begun)+        ledger.endSweep(generation: first)+        let begunAgain = ledger.beginSweep()+        let second = try #require(begunAgain)++        ledger.endSweep(generation: first)++        #expect(ledger.isSweeping(generation: second))+        #expect(ledger.beginSweep() == nil)+    }++    // MARK: - Background gates (Reqs 5.2, 5.3)++    @Test("The background sweep is refused once the run budget is spent")+    func backgroundRefusedOnBudget() {+        var ledger = Self.settled("a.example", .noSuggestion,+                                  modelPhase: RuleSuggestionBounds.runTimeBudget)++        let outcome = ledger.start(hostname: "b.example", origin: .background,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(ledger.budgetExhausted)+        #expect(outcome == .refuse(.budgetExhausted))+    }++    @Test("Budget exhaustion does not stop reader-initiated attempts (Q31)",+          arguments: [Origin.open, .request])+    func budgetBlocksBackgroundOnly(origin: Origin) {+        var ledger = Self.settled("a.example", .noSuggestion,+                                  modelPhase: RuleSuggestionBounds.runTimeBudget)++        let outcome = ledger.start(hostname: "b.example", origin: origin,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .start)+    }++    @Test("The background sweep is refused in Low Power Mode, on heat, and when inactive",+          arguments: zip(+              [+                  RuleSuggestionEnvironment(isActive: true, isLowPowerMode: true, thermalState: .nominal),+                  RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false, thermalState: .serious),+                  RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false, thermalState: .critical),+                  RuleSuggestionEnvironment(isActive: false, isLowPowerMode: false, thermalState: .nominal),+              ],+              [RefusalReason.lowPower, .thermallyConstrained, .thermallyConstrained, .notActive]))+    func backgroundGates(environment: RuleSuggestionEnvironment, reason: RefusalReason) {+        var ledger = RuleSuggestionLedger()++        let outcome = ledger.start(hostname: "a.example", origin: .background,+                                   fingerprint: Self.fingerprint(), environment: environment)++        #expect(outcome == .refuse(reason))+        #expect(ledger.inFlight == nil)+    }++    @Test("A fair thermal state does not stop the sweep")+    func fairThermalIsFine() {+        var ledger = RuleSuggestionLedger()+        let environment = RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false,+                                                    thermalState: .fair)++        let outcome = ledger.start(hostname: "a.example", origin: .background,+                                   fingerprint: Self.fingerprint(), environment: environment)++        #expect(outcome == .start)+    }++    @Test("Reader-initiated attempts ignore the Low Power and thermal gates (Req 5.3)",+          arguments: [Origin.open, .request])+    func gatesDoNotApplyToReaderAttempts(origin: Origin) {+        var ledger = RuleSuggestionLedger()+        let environment = RuleSuggestionEnvironment(isActive: false, isLowPowerMode: true,+                                                    thermalState: .critical)++        let outcome = ledger.start(hostname: "a.example", origin: origin,+                                   fingerprint: Self.fingerprint(), environment: environment)++        #expect(outcome == .start)+    }++    // MARK: - Settlement (Reqs 5.2, 5.4, 5.10, Q28)++    @Test("A settled attempt holds its suggestion, marks the hostname attempted, and charges")+    func successSettles() {+        let ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")),+                                  modelPhase: .seconds(4))++        #expect(ledger.held(for: "a.example") == Self.suggestion("a.example"))+        #expect(ledger.isAttempted("a.example"))+        #expect(ledger.budgetSpent == .seconds(4))+        #expect(ledger.inFlight == nil)+    }++    @Test("An attempt that produced nothing still marks the hostname attempted")+    func noSuggestionSettles() {+        let ledger = Self.settled("a.example", .noSuggestion, modelPhase: .seconds(2))++        #expect(ledger.held(for: "a.example") == nil)+        #expect(ledger.isAttempted("a.example"))+        #expect(ledger.budgetSpent == .seconds(2))+    }++    @Test("A timeout marks the hostname attempted and charges the budget")+    func timeoutSettles() {+        let ledger = Self.settled("a.example", .timedOut,+                                  modelPhase: RuleSuggestionBounds.attemptTimeout)++        #expect(ledger.isAttempted("a.example"))+        #expect(ledger.budgetSpent == RuleSuggestionBounds.attemptTimeout)+        #expect(ledger.inFlight == nil)+    }++    @Test("A system cancellation charges the budget but leaves the hostname attemptable")+    func cancellationSettles() {+        var ledger = Self.settled("a.example", .cancelled, modelPhase: .seconds(3))+        let restart = ledger.start(hostname: "a.example", origin: .background,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.budgetSpent == .seconds(3))+        #expect(restart == .start)+    }++    @Test("A settled attempt with no sides is not held, but the hostname is attempted")+    func emptySuggestionIsNotHeld() {+        let ledger = Self.settled("a.example",+                                  .suggestion(RuleSuggestion(hostname: "a.example")),+                                  modelPhase: .seconds(2))++        #expect(ledger.held(for: "a.example") == nil)+        #expect(ledger.isAttempted("a.example"))+        #expect(ledger.budgetSpent == .seconds(2))+        #expect(ledger.inFlight == nil)+    }++    @Test("Settling a hostname that is not the one in flight leaves the in-flight record alone")+    func staleSettleIsIgnored() {+        var ledger = Self.running("a.example")++        ledger.settle(hostname: "b.example", .cancelled, modelPhase: .seconds(1))++        #expect(ledger.inFlight?.hostname == "a.example")+        #expect(ledger.budgetSpent == .seconds(1))+    }++    // MARK: - Dismissal (Reqs 2.7, 2.8, 6.5)++    @Test("Dismissal is per side and per hostname")+    func dismissalIsPerSide() {+        var ledger = RuleSuggestionLedger()++        ledger.dismiss(hostname: "a.example", side: .title)++        #expect(ledger.isDismissed(hostname: "a.example", side: .title))+        #expect(!ledger.isDismissed(hostname: "a.example", side: .url))+        #expect(!ledger.isDismissed(hostname: "b.example", side: .title))+    }++    @Test("The Suggest action clears one side's dismissal")+    func dismissalCleared() {+        var ledger = RuleSuggestionLedger()+        ledger.dismiss(hostname: "a.example", side: .title)+        ledger.dismiss(hostname: "a.example", side: .url)++        ledger.clearDismissal(hostname: "a.example", side: .title)++        #expect(!ledger.isDismissed(hostname: "a.example", side: .title))+        #expect(ledger.isDismissed(hostname: "a.example", side: .url))+    }++    // MARK: - Invalidation (Req 5.5, Q18)++    @Test("Invalidation clears the held suggestion, the attempt and the fingerprint")+    func invalidateClearsRunState() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))++        let mustCancel = ledger.invalidate(hostname: "a.example")++        #expect(!mustCancel)+        #expect(ledger.held(for: "a.example") == nil)+        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.fingerprints["a.example"] == nil)+        #expect(ledger.budgetSpent == .seconds(1))+    }++    @Test("Invalidation does not clear a dismissal (Req 2.8)")+    func invalidateKeepsDismissals() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))+        ledger.dismiss(hostname: "a.example", side: .title)++        ledger.invalidate(hostname: "a.example")++        #expect(ledger.isDismissed(hostname: "a.example", side: .title))+    }++    @Test("Invalidating the hostname in flight asks the caller to cancel it")+    func invalidateCancelsInFlight() {+        var ledger = Self.running("a.example")++        let running = ledger.invalidate(hostname: "a.example")+        let other = ledger.invalidate(hostname: "b.example")++        #expect(running)+        #expect(!other)+    }++    // MARK: - Voided attempts (Reqs 5.5, 5.6, Q54)++    @Test("A result that lands after invalidation is discarded, not held")+    func invalidatedAttemptCannotResurrectHeldState() {+        var ledger = Self.running("a.example")+        ledger.invalidate(hostname: "a.example")++        // The coordinator has asked for the cancellation, but the model+        // answered first: the answer is about a corpus that no longer exists.+        ledger.settle(hostname: "a.example", .suggestion(Self.suggestion("a.example")),+                      modelPhase: .seconds(3))++        #expect(ledger.held(for: "a.example") == nil)+        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.fingerprints["a.example"] == nil)+        // Charged like any other ending (Req 5.2), and the slot is free again.+        #expect(ledger.budgetSpent == .seconds(3))+        #expect(ledger.inFlight == nil)+    }++    @Test("Every settlement for a voided attempt is treated as a cancellation",+          arguments: [AttemptSettlement.noSuggestion, .timedOut, .cancelled])+    func voidedAttemptSettlesAsCancelled(settlement: AttemptSettlement) {+        var ledger = Self.running("a.example")+        ledger.invalidate(hostname: "a.example")++        ledger.settle(hostname: "a.example", settlement, modelPhase: .seconds(1))++        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.budgetSpent == .seconds(1))+        #expect(ledger.inFlight == nil)+    }++    @Test("A result that lands after a memory warning is discarded, not held")+    func memoryWarningVoidsTheAttempt() {+        var ledger = Self.running("a.example")+        _ = ledger.memoryWarning()++        ledger.settle(hostname: "a.example", .suggestion(Self.suggestion("a.example")),+                      modelPhase: .seconds(2))++        #expect(ledger.held(for: "a.example") == nil)+        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.inFlight == nil)+    }++    @Test("A result that lands after a reconcile mismatch is discarded, not held")+    func reconcileVoidsTheAttempt() {+        var ledger = Self.running("a.example")++        let invalidated = ledger.reconcile(against: ["a.example": Self.fingerprint(entryCount: 9)])+        ledger.settle(hostname: "a.example", .suggestion(Self.suggestion("a.example")),+                      modelPhase: .seconds(1))++        #expect(invalidated == ["a.example"])+        #expect(ledger.held(for: "a.example") == nil)+        #expect(!ledger.isAttempted("a.example"))+    }++    @Test("A voided attempt is not attached to; the reader's start pre-empts it instead",+          arguments: [Origin.open, .request])+    func voidedAttemptIsNotAttachedTo(origin: Origin) {+        var ledger = Self.running("a.example")+        ledger.invalidate(hostname: "a.example")++        let outcome = ledger.start(hostname: "a.example", origin: origin,+                                   fingerprint: Self.fingerprint(entryCount: 9),+                                   environment: Self.foreground)++        #expect(outcome == .preempt(hostname: "a.example"))+    }++    @Test("An untouched attempt still attaches")+    func liveAttemptStillAttaches() {+        var ledger = Self.running("a.example")+        ledger.invalidate(hostname: "b.example")++        let outcome = ledger.start(hostname: "a.example", origin: .open,+                                   fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(outcome == .attach)+    }++    // MARK: - Reconcile (Q43)++    @Test("A changed candidate row invalidates its hostname")+    func fingerprintMismatchInvalidates() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))++        let invalidated = ledger.reconcile(against: ["a.example": Self.fingerprint(entryCount: 4)])++        #expect(invalidated == ["a.example"])+        #expect(ledger.held(for: "a.example") == nil)+        #expect(!ledger.isAttempted("a.example"))+    }++    @Test("A newly stored rule version invalidates the hostname")+    func ruleVersionMismatchInvalidates() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))++        let invalidated = ledger.reconcile(+            against: ["a.example": Self.fingerprint(titleRuleVersion: 1)])++        #expect(invalidated == ["a.example"])+    }++    @Test("An unchanged candidate row invalidates nothing")+    func fingerprintMatchKeepsState() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))++        let invalidated = ledger.reconcile(against: ["a.example": Self.fingerprint()])++        #expect(invalidated.isEmpty)+        #expect(ledger.held(for: "a.example") != nil)+        #expect(ledger.isAttempted("a.example"))+    }++    @Test("A tracked hostname the library no longer reports is invalidated")+    func disappearedHostnameInvalidates() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))++        let invalidated = ledger.reconcile(against: [:])++        #expect(invalidated == ["a.example"])+        #expect(ledger.held(for: "a.example") == nil)+    }++    @Test("Tracked hostnames are the held, attempted and in-flight ones")+    func trackedHostnames() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))+        _ = ledger.start(hostname: "b.example", origin: .background,+                         fingerprint: Self.fingerprint(),+                         environment: Self.foreground)+        ledger.settle(hostname: "b.example", .noSuggestion, modelPhase: .seconds(1))+        _ = ledger.start(hostname: "c.example", origin: .open,+                         fingerprint: Self.fingerprint(),+                         environment: Self.foreground)+        ledger.dismiss(hostname: "d.example", side: .title)++        #expect(ledger.trackedHostnames == ["a.example", "b.example", "c.example"])+    }++    // MARK: - Memory warning (Req 5.6, Q50)++    @Test("A memory warning drops held suggestions, attempts and fingerprints, and cancels in flight")+    func memoryWarning() {+        var ledger = Self.settled("a.example", .suggestion(Self.suggestion("a.example")))+        ledger.dismiss(hostname: "a.example", side: .url)+        _ = ledger.start(hostname: "b.example", origin: .open,+                         fingerprint: Self.fingerprint(),+                         environment: Self.foreground)++        let toCancel = ledger.memoryWarning()++        #expect(toCancel == "b.example")+        #expect(ledger.held(for: "a.example") == nil)+        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.fingerprints.isEmpty)+        #expect(ledger.isDismissed(hostname: "a.example", side: .url))+        #expect(ledger.budgetSpent == .seconds(1))+    }++    @Test("A memory warning with nothing in flight asks for no cancellation")+    func memoryWarningWithoutAnAttempt() {+        var ledger = RuleSuggestionLedger()++        let toCancel = ledger.memoryWarning()++        #expect(toCancel == nil)+    }++    // MARK: - Resign active (Req 5.3, Q28)++    @Test("Resigning active cancels a background attempt and ends the sweep")+    func resignActiveCancelsBackground() {+        var ledger = Self.running("a.example", origin: .background)++        let toCancel = ledger.resignActive()++        #expect(toCancel == "a.example")+        #expect(!ledger.sweepActive)+        // The cancellation itself is settled by the coordinator, which leaves+        // the hostname attemptable.+        ledger.settle(hostname: "a.example", .cancelled, modelPhase: .seconds(1))+        #expect(!ledger.isAttempted("a.example"))+    }++    @Test("Resigning active leaves reader-initiated attempts running (Req 5.3)",+          arguments: [Origin.open, .request])+    func resignActiveLeavesReaderAttempts(origin: Origin) {+        var ledger = Self.running("a.example", origin: origin)++        let toCancel = ledger.resignActive()++        #expect(toCancel == nil)+        #expect(ledger.inFlight?.hostname == "a.example")+        #expect(!ledger.sweepActive)+    }++    // MARK: - Fingerprints++    @Test("A fingerprint is recorded when the attempt starts")+    func fingerprintRecordedAtStart() {+        var ledger = RuleSuggestionLedger()++        _ = ledger.start(hostname: "a.example", origin: .background,+                         fingerprint: Self.fingerprint(), environment: Self.foreground)++        #expect(ledger.fingerprints["a.example"] == Self.fingerprint())+    }++    @Test("An in-flight attempt is fingerprinted, so a matching reconcile leaves it alone")+    func inFlightFingerprintSurvivesReconcile() {+        var ledger = Self.running("a.example")++        let invalidated = ledger.reconcile(against: ["a.example": Self.fingerprint()])++        #expect(invalidated.isEmpty)+        #expect(ledger.inFlight?.voided == false)+    }++    @Test("A refused start records nothing")+    func refusedStartRecordsNothing() {+        var ledger = RuleSuggestionLedger()+        let environment = RuleSuggestionEnvironment(isActive: false, isLowPowerMode: false,+                                                    thermalState: .nominal)++        _ = ledger.start(hostname: "a.example", origin: .background,+                         fingerprint: Self.fingerprint(), environment: environment)++        #expect(ledger.fingerprints.isEmpty)+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/StubRuleSuggestionModelClientTests.swift Added +154 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/StubRuleSuggestionModelClientTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/StubRuleSuggestionModelClientTests.swiftnew file mode 100644index 0000000..c352e0a--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/StubRuleSuggestionModelClientTests.swift@@ -0,0 +1,154 @@+import Foundation+import Testing++@testable import AsterismIntelligence++/// The stub is a test fixture, but the coordinator and suggester suites will+/// lean on it to assert "no model call" and to drive Req 3.7's halve-and-retry,+/// so its recording and scripting are worth pinning down here.+@Suite("StubRuleSuggestionModelClient")+struct StubRuleSuggestionModelClientTests {+    static func corpus(_ hostname: String = "example.net", contextCount: Int = 0) -> SuggestionCorpus {+        SuggestionCorpus(+            hostname: hostname,+            anchor: SuggestionExample(title: "Sylver Seeker - Chapter 12",+                                      rawURL: "https://example.net/sylver-seeker/12"),+            context: (0..<contextCount).map {+                SuggestionExample(title: "Sylver Seeker - Chapter \(11 - $0)",+                                  rawURL: "https://example.net/sylver-seeker/\(11 - $0)")+            })+    }++    static let proposal = RuleProposal(workName: "Sylver Seeker", chapterText: "Chapter 12",+                                       urlWorkIdentity: "sylver-seeker", urlSequenceText: "12")++    // MARK: - Call log++    @Test("Every corpus propose is given is recorded, in order")+    func recordsEveryCall() async throws {+        let client = StubRuleSuggestionModelClient(proposal: Self.proposal)++        _ = try await client.propose(Self.corpus("a.example"))+        _ = try await client.propose(Self.corpus("b.example"))++        #expect(client.recorder.callCount == 2)+        #expect(client.recorder.recordedCorpora.map(\.hostname) == ["a.example", "b.example"])+    }++    @Test("A client never asked for a proposal has recorded nothing")+    func recordsNothingWithoutACall() {+        let client = StubRuleSuggestionModelClient(proposal: Self.proposal)++        #expect(client.recorder.callCount == 0)+        #expect(client.recorder.recordedCorpora.isEmpty)+    }++    @Test("A failing call is recorded too")+    func recordsFailures() async {+        let client = StubRuleSuggestionModelClient(+            error: StubRuleSuggestionModelClientError.contextWindowOverflow)++        await #expect(throws: StubRuleSuggestionModelClientError.contextWindowOverflow) {+            _ = try await client.propose(Self.corpus())+        }++        #expect(client.recorder.callCount == 1)+    }++    @Test("Copies of the stub share one log: the coordinator's copy is the test's copy")+    func copiesShareTheRecorder() async throws {+        let client = StubRuleSuggestionModelClient(proposal: Self.proposal)+        let copy = client++        _ = try await copy.propose(Self.corpus("a.example"))++        #expect(client.recorder.callCount == 1)+    }++    // MARK: - Scripted results++    @Test("Scripted results are consumed in order and the last one repeats")+    func scriptIsConsumedInOrder() async throws {+        let second = RuleProposal(workName: "Deep Blue Sky")+        let client = StubRuleSuggestionModelClient(+            results: [.failure(StubRuleSuggestionModelClientError.contextWindowOverflow),+                      .success(second)])++        await #expect(throws: StubRuleSuggestionModelClientError.contextWindowOverflow) {+            _ = try await client.propose(Self.corpus(contextCount: 4))+        }+        let first = try await client.propose(Self.corpus(contextCount: 2))+        let repeated = try await client.propose(Self.corpus(contextCount: 2))++        #expect(first == second)+        #expect(repeated == second)+        #expect(client.recorder.recordedCorpora.map(\.context.count) == [4, 2, 2])+    }++    @Test("An empty script falls back to the canned error, then the canned proposal")+    func scriptFallsBackToTheCannedAnswers() async throws {+        let cannedOnly = StubRuleSuggestionModelClient(proposal: Self.proposal)+        let withError = StubRuleSuggestionModelClient(+            proposal: Self.proposal,+            error: StubRuleSuggestionModelClientError.noCannedProposal)++        #expect(try await cannedOnly.propose(Self.corpus()) == Self.proposal)+        await #expect(throws: StubRuleSuggestionModelClientError.noCannedProposal) {+            _ = try await withError.propose(Self.corpus())+        }+    }++    @Test("A stub with neither a script nor a canned proposal says so")+    func noCannedProposal() async {+        let client = StubRuleSuggestionModelClient()++        await #expect(throws: StubRuleSuggestionModelClientError.noCannedProposal) {+            _ = try await client.propose(Self.corpus())+        }+    }++    @Test("The delay still applies, and the call is logged before it")+    func delayStillApplies() async throws {+        let client = StubRuleSuggestionModelClient(proposal: Self.proposal,+                                                   delay: .milliseconds(20))++        let started = ContinuousClock.now+        _ = try await client.propose(Self.corpus())++        #expect(ContinuousClock.now - started >= .milliseconds(20))+        #expect(client.recorder.callCount == 1)+    }++    @Test("The stub reports the availability it was given")+    func availabilityIsScripted() {+        let client = StubRuleSuggestionModelClient(availability: .unavailable(reason: "test"))++        #expect(client.availability() == .unavailable(reason: "test"))+    }++    // MARK: - Overflow recognition (Req 3.7, Q56)++    @Test("The stub recognises its own overflow error through the protocol")+    func recognisesItsOwnOverflow() {+        let client: any RuleSuggestionModelClient = StubRuleSuggestionModelClient()++        #expect(client.isContextWindowOverflow(StubRuleSuggestionModelClientError+            .contextWindowOverflow))+        #expect(!client.isContextWindowOverflow(StubRuleSuggestionModelClientError+            .noCannedProposal))+        #expect(!client.isContextWindowOverflow(CancellationError()))+    }++    @Test("A client that says nothing about overflow never overflows")+    func defaultImplementationSaysNo() {+        struct SilentClient: RuleSuggestionModelClient {+            func availability() -> ModelAvailability { .available }+            func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal { RuleProposal() }+        }++        let client: any RuleSuggestionModelClient = SilentClient()++        #expect(!client.isContextWindowOverflow(StubRuleSuggestionModelClientError+            .contextWindowOverflow))+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/SuggestionCorpusTests.swift Added +141 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/SuggestionCorpusTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/SuggestionCorpusTests.swiftnew file mode 100644index 0000000..96efb85--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/SuggestionCorpusTests.swift@@ -0,0 +1,141 @@+import AsterismCore+import Foundation+import Testing++@testable import AsterismIntelligence++@Suite("SuggestionCorpus selection")+struct SuggestionCorpusTests {+    // MARK: - Fixtures++    static let epoch = Date(timeIntervalSince1970: 1_700_000_000)++    /// A basis entry with only the fields corpus selection reads.+    static func entry(_ title: String, _ url: String, minutes: Int,+                      id: UUID = UUID()) -> ComposedEntryBasis {+        ComposedEntryBasis(+            id: id,+            captureTitle: title,+            rawURLString: url,+            hostname: "example.net",+            firstCapturedAt: epoch.addingTimeInterval(TimeInterval(minutes * 60)),+            chapterTitle: nil,+            chapterTitleProvenance: .none,+            workID: nil,+            workAssignmentProvenance: .none,+            intentionallyUnattached: false+        )+    }++    // MARK: - Ordering (Req 3.7, Q24)++    @Test("The newest capture is the anchor and context follows newest first")+    func recencyOrder() throws {+        let entries = [+            Self.entry("Ch 1", "https://example.net/c/1", minutes: 10),+            Self.entry("Ch 3", "https://example.net/c/3", minutes: 30),+            Self.entry("Ch 2", "https://example.net/c/2", minutes: 20),+        ]++        let corpus = try #require(SuggestionCorpus.make(from: entries, hostname: "example.net"))++        #expect(corpus.hostname == "example.net")+        #expect(corpus.anchor == SuggestionExample(title: "Ch 3", rawURL: "https://example.net/c/3"))+        #expect(corpus.context.map(\.title) == ["Ch 2", "Ch 1"])+        #expect(corpus.examples.map(\.title) == ["Ch 3", "Ch 2", "Ch 1"])+    }++    @Test("Captures sharing a timestamp are ordered by id, so the corpus is reproducible")+    func identityTieBreak() throws {+        let low = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")!+        let high = UUID(uuidString: "FFFFFFFF-0000-0000-0000-0000000000AA")!+        let entries = [+            Self.entry("high", "https://example.net/h", minutes: 10, id: high),+            Self.entry("low", "https://example.net/l", minutes: 10, id: low),+        ]++        let forwards = try #require(SuggestionCorpus.make(from: entries, hostname: "example.net"))+        let backwards = try #require(SuggestionCorpus.make(from: entries.reversed(),+                                                           hostname: "example.net"))++        #expect(forwards == backwards)+        #expect(forwards.examples.map(\.title) == ["low", "high"])+    }++    // MARK: - Bounds++    @Test("At most captureSampleCount examples are shown, newest kept")+    func capped() throws {+        let entries = (1 ... 8).map { Self.entry("Ch \($0)", "https://example.net/c/\($0)", minutes: $0) }++        let corpus = try #require(SuggestionCorpus.make(from: entries, hostname: "example.net"))++        #expect(corpus.examples.count == RuleSuggestionBounds.captureSampleCount)+        #expect(corpus.context.count == RuleSuggestionBounds.captureSampleCount - 1)+        #expect(corpus.examples.map(\.title) == ["Ch 8", "Ch 7", "Ch 6", "Ch 5", "Ch 4"])+    }++    @Test("Two captures produce two examples, not one")+    func atLeastTwoWhenTwoExist() throws {+        let entries = [+            Self.entry("Ch 1", "https://example.net/c/1", minutes: 10),+            Self.entry("Ch 2", "https://example.net/c/2", minutes: 20),+        ]++        let corpus = try #require(SuggestionCorpus.make(from: entries, hostname: "example.net"))++        #expect(corpus.examples.count == 2)+        #expect(corpus.context.count == 1)+    }++    @Test("One capture is a corpus with no context")+    func singleCapture() throws {+        let corpus = try #require(SuggestionCorpus.make(+            from: [Self.entry("Ch 1", "https://example.net/c/1", minutes: 10)],+            hostname: "example.net"))++        #expect(corpus.context.isEmpty)+        #expect(corpus.examples.count == 1)+    }++    @Test("No captures is no corpus")+    func noCaptures() {+        #expect(SuggestionCorpus.make(from: [], hostname: "example.net") == nil)+    }++    // MARK: - Halving on context overflow (Req 3.7)++    @Test("Halving drops the oldest context entries and floors at the anchor alone")+    func halvingDownToTheAnchor() throws {+        let entries = (1 ... 5).map { Self.entry("Ch \($0)", "https://example.net/c/\($0)", minutes: $0) }+        let corpus = try #require(SuggestionCorpus.make(from: entries, hostname: "example.net"))+        #expect(corpus.context.count == 4)++        let once = try #require(corpus.halved())+        #expect(once.anchor == corpus.anchor)+        #expect(once.context.map(\.title) == ["Ch 4", "Ch 3"])++        let twice = try #require(once.halved())+        #expect(twice.context.map(\.title) == ["Ch 4"])++        let thrice = try #require(twice.halved())+        #expect(thrice.context.isEmpty)+        #expect(thrice.anchor == corpus.anchor)++        // The anchor alone is the floor: there is nothing left to halve.+        #expect(thrice.halved() == nil)+    }++    @Test("A one-context corpus halves straight to the anchor")+    func halvingASingleContextEntry() throws {+        let entries = [+            Self.entry("Ch 1", "https://example.net/c/1", minutes: 10),+            Self.entry("Ch 2", "https://example.net/c/2", minutes: 20),+        ]+        let corpus = try #require(SuggestionCorpus.make(from: entries, hostname: "example.net"))++        let halved = try #require(corpus.halved())+        #expect(halved.context.isEmpty)+        #expect(halved.halved() == nil)+    }+}
docs/agent-notes/composed-teaching-ui.md Modified +20 / -0
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex 67d038e..533f5ce 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -103,3 +103,23 @@ collapses the subtree into one element and hides its buttons from XCUITest (and from VoiceOver as separate controls). That is what made the unsettled-chapters acknowledgment button unqueryable. Containers that hold controls need both modifiers.++A `Button` has the same effect on its own label: the label is merged into the+button's single element, so an identifier on anything inside it is unreachable.+The suggest control's spinner (`composed-suggest-busy`) is therefore an+`.overlay` on the button rather than part of its `Label`. The suggest control+itself heads `editorContent` (above the title chips), not the commit row.++A second trap, inside `urlDisclosure`: the `DisclosureGroup` carries+`.accessibilityIdentifier("composed-url-disclosure")`, and every *container*+element one level under it — a row with `.accessibilityElement(children: .contain)`+— comes out of the tree carrying **`composed-url-disclosure`** instead of its+own identifier. Leaves keep theirs (`composed-url-clear`,+`composed-url-remedy-hint`), and deeper containers keep theirs+(`composed-url-path-chips`). That silently swallowed the URL side's "Suggested"+marker: the row existed, its clear button was queryable, and+`composed-url-suggested` matched nothing. Inside that disclosure, put a+queryable identifier on a **leaf** (a `Label` with+`.accessibilityElement(children: .combine)`), not on the row. The symptom is a+UI test failing on an element you can see in `app.debugDescription` under+another identifier — dump the subtree before assuming the state is wrong.
docs/agent-notes/testing.md Modified +17 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 5c55613..2c90e39 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -29,6 +29,23 @@ change was the share extension plist; it passed in isolation and on the full rerun. One isolated failure of this test is not a regression signal; rerun before investigating. +## Adding recorded state to `MockLibraryProvider` needs a lock++`MockLibraryProvider` is `@unchecked Sendable` and its counters are plain+mutable properties, which is fine for the suites that drive one call at a time.+`ComposedTeachingViewModelTests` is not one of them: the generation-gating tests+(`obsoleteGenerationCannotPublish`, the pending-anchoring disclosure tests) use+`composedProjectionDelay` to hold **two** `projectComposedTeaching` calls open at+once, deliberately.++Adding an array append to `projectComposedTeaching` (a request log, for the+rule-suggestion suggester tests) therefore made those tests fail intermittently+with a *different* name each run, while the Swift Testing output showed the same+test passing — an array reallocating under a concurrent append, not an assertion+failing. The fix is a lock around the new state, not around the whole mock; see+`projectComposedRequests`. Anything else recorded per call in a method a test can+overlap needs the same treatment.+ ## Known flaky family: the store-digest comparisons (AsterismCore)  Several core tests assert that an operation which must not write left the
docs/asterism-v2-plan.md Modified +15 / -3
diff --git a/docs/asterism-v2-plan.md b/docs/asterism-v2-plan.mdindex 55f1311..bfee3f8 100644--- a/docs/asterism-v2-plan.md+++ b/docs/asterism-v2-plan.md@@ -2,7 +2,10 @@  **Status:** in progress. Item 1 shipped 2026-08-10 ([`specs/optional-chapter-sequence/`](../specs/optional-chapter-sequence/), PR #18);-item 2 is the next to start. Nothing else has begun.+item 2 shipped 2026-08-17+([`specs/pending-capture-queue/`](../specs/pending-capture-queue/), PR #27);+item 3 implemented 2026-08-18 on branch `T-2156/rule-suggestion`+([`specs/rule-suggestion/`](../specs/rule-suggestion/), PR pending). Nothing else has begun. **Source of truth for *what* v2 contains:** §11 "v2+" of [`asterism-design.md`](asterism-design.md). This file holds the *ordering* and a per-item brief detailed enough to start a spec from. Where the design document@@ -20,8 +23,8 @@ lists are managed with `rune`; decision logs use the two-tier format. | # | Item | Size | Schema | Archive format | Gated on | |---|------|------|--------|----------------|----------| | ~~1~~ | ~~[Optional trailing sequence in a combined URL rule](#1-optional-trailing-sequence-in-a-combined-url-rule)~~ — **done 2026-08-10** | Small | None | None needed | — |-| 2 | [Pending-capture queue](#2-pending-capture-queue) | Small–medium | None | None | Nothing |-| 3 | [AI-suggested taught rules](#3-ai-suggested-taught-rules) | Small–medium | None | None | Nothing |+| ~~2~~ | ~~[Pending-capture queue](#2-pending-capture-queue)~~ — **done 2026-08-17** | Small–medium | None | None | — |+| ~~3~~ | ~~[AI-suggested taught rules](#3-ai-suggested-taught-rules)~~ — **implemented 2026-08-18, PR pending** | Small–medium | None | None | — | | 4 | [Character extraction and the Character schema](#4-character-extraction-and-the-character-schema) | Large | New entity | Yes | 3 (for the integration) | | 5 | [Q&A over a work's notes](#5-qa-over-a-works-notes) | Medium | None | None | 4 | | 6 | [Thumbnails](#6-thumbnails) | Small–medium | One field | Probably none | Nothing |@@ -163,6 +166,15 @@ the optional-sequence case honestly.  ## 2. Pending-capture queue +> **Done — shipped 2026-08-17** (PR #27, all 20 tasks). The spec of record is+> [`specs/pending-capture-queue/`](../specs/pending-capture-queue/);+> `specs/OVERVIEW.md` carries the current status. It resolved differently from+> the brief below in two respects: the extension writes *every* share to the+> queue before opening the library rather than only on a readiness failure, and+> the bound is bytes rather than a count (Decisions 4 and 7). Two device checks+> remain open in its `prerequisites.md`. The brief is left as the record of+> the reasoning.+ **Ticket:** T-2217.  **Why here.** Every remaining item is a schema or format change, and each one
specs/OVERVIEW.md Modified +25 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 78e68f7..93e19e9 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -21,6 +21,7 @@ | [Recent Window Cap](#recent-window-cap) | 2026-08-15 | Done — all 8 tasks implemented, reviewed and green | Caps the Recent list at the newest 100 logical rows (T-2191), with everything beyond it reachable through Works behind a truncation footer. A 14-day window was specified and then dropped (Decision 2): it would empty Recent for a reader returning after a break. Entirely app-layer — `AsterismCore`, the fixtures and the performance suites are untouched, because the cap is applied where the screen reads the presentation rather than in the derivation (Decision 1). | | [Pending-Capture Queue](#pending-capture-queue) | 2026-08-16 | Done — all 20 tasks implemented 2026-08-17; two device checks (protection class before first unlock, share-flow fsync latency) remain open in `prerequisites.md` | v2 plan item 2 (T-2217). The share extension writes every share to a durable record in the App Group container **before** opening the library, and removes it only once the capture commits or the reader cancels; anything failing in between leaves the record for the app to drain on its next activation. No schema, migration or archive change. The directory layout is the queue state (Decision 7) — one file per record, every transition an atomic rename. Reverses two earlier calls after review: a busy library no longer retries (Decision 3, whose original premise about `bootstrapLockTimeout` was wrong), and the queue bounds bytes rather than a count of 100 (Decision 4). | | [Stats Page](#stats-page) | 2026-08-16 | Done — all 16 tasks implemented; Reqs 7.1, 7.2 and 7.5's visual half await the reader's own device check (`prerequisites.md`) | A third tab (T-2192) with two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day. Entirely app-layer (Decision 5): notes, dates and work attribution come from `recentPresentation.allRows`, the works total from `worksSnapshot` — `AsterismCore` is untouched. The graph counts **first captures**, so re-reads are deliberately invisible (Decision 1). Supersedes in part `polish-and-export` Reqs 9.3 and 11.2, design §5, and style guide §7/§8. |+| [Rule Suggestion](#rule-suggestion) | 2026-08-17 | Done | v2 plan item 3 (T-2156). The on-device Foundation Model proposes a title rule and URL rule for an untaught hostname from its captures; the composed teaching editor opens pre-filled with a "Suggested" marker and the reader saves as normal. Suggestions are precomputed in the background, verified against every capture on the hostname before they are shown, never written without a save, and their absence — model unavailable, verification failed, not finished — leaves the editor exactly as today. First `FoundationModels` use in the tree; adds an `AsterismIntelligence` package target the extension never links. No schema, migration or archive change. |  --- @@ -337,3 +338,27 @@ A third tab (T-2192) holding two lifetime totals, a bar graph of reading activit - [decision_log.md](stats-page/decision_log.md) - [tasks.md](stats-page/tasks.md) - [prerequisites.md](stats-page/prerequisites.md)++---++## Rule Suggestion++**Created:** 2026-08-17 · **Status:** Done — all 14 tasks implemented 2026-08-18; the on-device latency spike in `prerequisites.md` is still outstanding and needs explicit approval to run.++v2 plan item 3 (T-2156). The on-device model proposes, for one hostname, the work-name and chapter text of the newest capture's title and the identity and sequence text of its raw URL; the existing editor inference turns those substrings into rules, the existing whole-hostname projection verifies them, and the composed teaching editor seeds the survivors as it seeds a stored rule.++**Postures worth knowing before reading the spec:**++- **The model returns substrings, never offsets** (Decision 1); code locates them and a repeated or absent substring loses that field. **The held artefact is a rule, not spans** (Q14), because the editor opens on an entry the sweep cannot know.+- **Model-facing code lives in a new `AsterismIntelligence` package target** (Decision 2), host-tested under `make test-core`; the extension keeps linking only `AsterismCore` (Req 4.4).+- **Precompute is bounded**: 3 hostnames per activation, a 60 s per-run model-time budget, a 10 s attempt bound, a 2 s auto-apply window — all provisional named constants pending the spike (Q11, Q17, Q22).+- **Both sides are model-derived; URL diffing may only corroborate** (Q10) — the parked v2 "URL-identity auto-suggestion via URL diffing" item is folded in that far and no further.+- **Invalidation is one `reconcile()` after `refreshAll()`** comparing candidate fingerprints (Q43); dismissal is per side per run and survives it (Q18).+- **Minimum corpus is one capture on both sides** (Q8), accepted against reviewer advice; single-capture verification is vacuous by construction.++- [requirements.md](rule-suggestion/requirements.md)+- [design.md](rule-suggestion/design.md)+- [decision_log.md](rule-suggestion/decision_log.md)+- [tasks.md](rule-suggestion/tasks.md)+- [prerequisites.md](rule-suggestion/prerequisites.md)+- [implementation.md](rule-suggestion/implementation.md)
specs/rule-suggestion/decision_log.md Added +155 / -0
diff --git a/specs/rule-suggestion/decision_log.md b/specs/rule-suggestion/decision_log.mdnew file mode 100644index 0000000..d824725--- /dev/null+++ b/specs/rule-suggestion/decision_log.md@@ -0,0 +1,155 @@+# Decision Log: Rule Suggestion++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-17 | Feature directory is `rule-suggestion` | Reader's choice over the plan's longer `ai-suggested-taught-rules`; the ticket and plan item cross-reference it |+| Q2 | 2026-08-17 | A suggestion is prefilled into the editor with a visible "suggested" marker; save is the ordinary save action | The "Use Suggested URL" precedent argues for visible provenance, but a separate accept step would make the suggestion a second editing mode. Prefill through the same selection path keeps editing indistinguishable from authoring |+| Q3 | 2026-08-17 | Suggestions are precomputed in the background on app activation, held in memory for the run | Reader chose readiness over simplicity: the editor should not open with a wait. In-memory avoids a file format and makes invalidation trivial at the cost of one model call per untaught hostname per launch |+| Q4 | 2026-08-17 | If no held suggestion exists on editor open, compute then and apply only if the reader has not touched that side | First-open on a brand-new site should still get a chance; applying over a selection the reader has begun would be hostile. Narrowed by Q22: auto-apply only within 2 s of the editor appearing |+| Q5 | 2026-08-17 | Held suggestions are invalidated by a new capture or a stored rule for the hostname, and are not re-applied after the reader edits, clears, or saves without them in the same run | Corpus change can break whole-hostname validity; a reader override is a signal not to nag. Trace is per-run only, so nothing persists. Refined by Q18 (dismissal per side, survives rule changes) and Q20 (invalidation on any corpus, mode or rule change) |+| Q6 | 2026-08-17 | Background scan covers untaught hostnames only; a taught site gets a suggestion only via an explicit editor action | Bounds model calls to sites that need teaching; the retained rule wins on open so re-teaching never silently shows a proposal over a working rule |+| Q7 | 2026-08-17 | Title and URL suggestions are independent | Either side failing validation should not cost the other |+| Q8 | 2026-08-17 | Minimum corpus is one capture, for both sides | Reaffirmed after review. Reviewers noted that at one capture whole-hostname validation is vacuous by construction and a wrong URL identity span could shard a work per chapter invisibly; the reader accepted that risk to keep first opens useful. Req 3.7 still feeds several captures when they exist |+| Q9 | 2026-08-17 | No Settings toggle | Model-unavailable is silent and nothing is written without acceptance; a toggle can be added if the feature proves intrusive |+| Q10 | 2026-08-17 | Both sides are model-derived; URL diffing across captures may inform or corroborate a proposal but never yields a suggestion on its own | Supersedes the earlier "design decides": the requirements gate everything on model availability, and a model-free URL path would need a second gate and a second surface. Diffing stays available to the design as evidence, and the parked entry is closed as folded-in-partially |+| Q11 | 2026-08-17 | Per-hostname attempt is abandoned after 10 s (a named constant), partial results discarded; one computation at a time | A bound is needed so a stuck model call cannot pin resources; the number is provisional until the latency spike and is deliberately not a reader-facing promise (see Q22) |+| Q12 | 2026-08-17 | The on-request action reports "no suggestion available" when nothing validates | Silence is the rule for the automatic path; an explicit request that visibly does nothing is a dead button |+| Q13 | 2026-08-17 | The articles exit is never proposed | Plan lean; the exit is a judgement about the site, not a parse of its text |+| Q14 | 2026-08-17 | A held suggestion is a *rule* (title definition + trims, URL definition), not raw spans; it is seeded onto the opened entry through the stored-rule seeding path | Design-critic C1: the editor applies spans over the entry it opened from, and a background computation cannot know which entry that will be. Seeding a rule reuses the existing inversion path and its cannot-depict fallback |+| Q15 | 2026-08-17 | Eligibility excludes sites in articles mode | Design-critic C2: "no stored rule" is also true of articles sites; suggesting a fiction rule there contradicts the reader's declaration and burns model calls |+| Q16 | 2026-08-17 | An editor open for a hostname mid-computation attaches to that computation; otherwise on-open work jumps ahead of the background queue | Design-critic C3: 5.4 and 5.7 otherwise conflict, and a slow sweep would make the reader wait behind unrelated hostnames |+| Q17 | 2026-08-17 | Sweep bounded: 3 hostnames per activation, most-recent capture first, one attempt per hostname per run, a 60 s cumulative model-time budget per run (named constant), not started in Low Power Mode or serious/critical thermal state, stopped on resign-active | Design-critic M1 and all three peer reviewers: `didBecomeActive` fires on every foreground return, so a per-activation count of 25 was minutes of Neural Engine work per return. Positions 1–3 carry nearly all the value because the hostname the reader opens is the one whose capture just landed; a time budget survives a change in measured latency where a count does not |+| Q18 | 2026-08-17 | Dismissal is per side per hostname per run, survives capture and rule changes, does not block the on-request action, and cancelling the editor does not dismiss | Design-critic M2 plus peer review: per-hostname dismissal killed an untouched URL suggestion when the reader edited the title (contradicting Q7), and letting rule-change invalidation clear a dismissal erased the reader's rejection in the same run |+| Q19 | 2026-08-17 | On a taught side, the commit stores the suggested rule rather than the retained one; the suggestion is not treated as an "edit" | Design-critic M3 found the retained definition would be reused verbatim; peer review found "counts as an edit" would trip marker removal and dismissal on the very action the reader requested |+| Q20 | 2026-08-17 | Invalidation covers any change to the hostname's capture set, Site mode, or stored rules, and makes the hostname attemptable again | Design-critic M4: deletions, re-teaching moves and CloudKit-merged captures change the corpus as much as a new local capture does |+| Q21 | 2026-08-17 | After seeding, the rule the editor re-derives from the resulting selection must be semantically equal to the verified suggestion, else the side is treated as cannot-depict | Peer review (code-grounded): with no retained rule the editor commits `selectedTitleRule`, re-derived from chips via `inferredTitleRule`, which can differ from the validated definition. Without this check Requirement 3's guarantee does not survive seeding |+| Q22 | 2026-08-17 | A result arriving more than 2 s after the editor appears is held and surfaced through the on-request action, not auto-applied | A late result would mutate a selection the reader is reading and, under VoiceOver, the accessibility tree mid-traversal. Two seconds is provisional until the latency spike |+| Q23 | 2026-08-17 | Opening the editor cancels an in-flight background computation for a different hostname | The reader's hostname must not wait behind an unrelated job with seconds left; that is the exact wait Q3 exists to remove |+| Q24 | 2026-08-17 | The model input is a bounded, deterministically chosen set of the hostname's most recent captures, at least two when two exist, shrunk rather than skipped on context overflow | Restores the plan brief's "feed several captures, not one", which the first draft dropped; determinism keeps a suggestion reproducible for a given corpus |+| Q25 | 2026-08-17 | The on-request action is hidden on articles-mode sites and shown on taught sites; the automatic path is scoped to auto-eligible hostnames only | Peer review: 1.6 as first written contradicted Requirement 6, and 6.1 exposed the action on articles sites against Q15 |+| Q26 | 2026-08-17 | Codex is unusable for peer review in this environment (malformed `~/.agents/skills/go-test-fixer/SKILL.md` and `release-prep/SKILL.md` make the CLI exit 1); the peer pass used Kiro plus two independent Claude lenses | Recorded so the next review cycle knows why only one external system contributed |+| Q27 | 2026-08-17 | When both sides have suggestions they are projected together before either is shown; a pair that fails together is reduced to whichever side passes alone | Second critic pass: the on-request action applies both at once, and a pair never projected together is exactly the state the reader would save |+| Q28 | 2026-08-17 | A system-cancelled attempt (resign-active, pre-empted by an editor open) leaves the hostname unattempted; only success, failure and timeout spend it | Otherwise the app's own pre-emption burns the three per-activation slots on hostnames that never ran |+| Q29 | 2026-08-17 | The on-request action applies a held result without a new model call, otherwise cancels the sweep and computes fresh regardless of prior attempts; timeout or failure shows the "no suggestion" message | Second critic pass: Requirement 6 said nothing about what a request does computationally |+| Q30 | 2026-08-17 | An on-open computation continues if the editor closes and its result is held | It is already paid for and the reader is likely to reopen the same hostname; cancelling would spend nothing and gain nothing |+| Q31 | 2026-08-17 | All model time counts toward the per-run budget; exhaustion stops only the background sweep | On-open and on-request work is reader-initiated and should not be starved by the sweep's spend |+| Q32 | 2026-08-17 | Attempt state is per hostname; suggestion, marker and dismissal state are per side | An attempt is one model call that yields both sides at once, so spending it per side would double model calls for nothing; what the reader sees and rejects is per side |+| Q33 | 2026-08-17 | An on-request application is not a "change" for dismissal or marker purposes | Second peer pass, verified at `ComposedTeachingViewModel.swift:179`/`:1132`: the retained rule is bypassed only via the same edit flag the reader-edit path sets, so the design must set the commit path without tripping 2.2/2.7 |+| Q34 | 2026-08-17 | Cannot-depict on the automatic path also suppresses the stored-rule notice | The stored-rule seeding path (Q14) sets a notice on a faithfulness miss; a suggestion that never applied must leave no trace |+| Q35 | 2026-08-17 | Verification is one whole-hostname `projectComposedTeaching` call per candidate set, not a separate applicator ladder | The projection already applies both rules to every entry and reports `titleFailure`/`urlFailure`/`workName`/`requiresUnsettledAcknowledgment` per row; running `TitleRuleApplicator`/`URLRuleApplicator` separately would re-implement it |+| Q36 | 2026-08-17 | The URL suggestion is assembled by driving a fresh `URLEditorState` headlessly and reading `rule(in:)` | Anchoring and template derivation are the editor's; a suggestion that took a different path could produce a rule the editor cannot depict, defeating Req 3.5. One internal `setSplit` is added so a within-component split needs no token gestures |+| Q37 | 2026-08-17 | Candidate hostnames come from a new `LibraryRepository.ruleSuggestionCandidates()` read, not `sites()` or `RecentPresentation` | `SiteSnapshot` carries neither URL-rule presence nor capture recency; Recent is capped at 100 rows and misses older untaught sites |+| Q38 | 2026-08-17 | `titleSelection`, `titleChips`, `inferredTitleRule` gain `nonisolated` | They are pure; the app target's default MainActor isolation is the only thing keeping them off the suggester actor |+| Q39 | 2026-08-17 | Empty strings, not optionals, denote "no chapter"/"no sequence" in the `@Generable` output | Keeps the schema small inside the 4,096-token window and avoids the explicit-nil representation flag |+| Q40 | 2026-08-17 | Coordinator is `@MainActor @Observable`, owned by `AppLibraryModel`; the attempt runs in a separate `RuleSuggester` actor | The view model is main-actor and needs synchronous reads of held/dismissed state; the model call and projections must not run on the main actor (Req 5.1) |+| Q41 | 2026-08-17 | Model-availability is re-read on every activation | `.modelNotReady` is transient (download in progress); a launch-time snapshot would hide the model for the whole run |+| Q42 | 2026-08-17 | The clear action restores the side's pre-suggestion snapshot (untaught initial state, or the retained rule on a taught side); Req 2.3 amended to match | Design critic: `resetSelectionToWholeTitle()` on a taught side leaves `titleEdited == false`, so the commit would silently store the retained rule while the chips showed whole-title |+| Q43 | 2026-08-17 | Invalidation is one `reconcile()` after `refreshAll()`, comparing candidate-row fingerprints for held/attempted/in-flight hostnames; a save of one side therefore invalidates the other side's still-valid suggestion, which is re-attempted on a later activation | Every app mutation already funnels through `refreshDiagnosesAndSnapshots`; scattered per-site hooks missed entry delete, articles toggle, curation and CloudKit arrivals, and the drain report carries entry ids, not hostnames. The extra model call is cheaper than a second invalidation path |+| Q44 | 2026-08-17 | A proposal with neither chapter text nor URL sequence text settles as no suggestion before any projection | The projection sets `requiresUnsettledAcknowledgment` for exactly that shape, so it can never pass Req 3.4; skipping saves up to three projections |+| Q45 | 2026-08-17 | Timeout is a distinct `AttemptTimeout` error thrown by the wrapper after it cancels the work; the model call's own `CancellationError` is the system-cancel signal | Both surface as cancellation at the model call; the ledger needs to tell them apart to mark attempted vs unattempted (Q28) |+| Q46 | 2026-08-17 | `RuleSuggestion`, `TitleRuleSuggestion` and `CorpusFingerprint` live in `AsterismIntelligence`, not the app target | The ledger holds them and the ledger is package-side; their fields are Core types, so no app dependency is needed. The app converts to/from `InferredTitleRule` at the assembly seam |+| Q47 | 2026-08-17 | URL-side edit detection hooks `updateURLRule(_:)`, not the editor's `beginGesture` | `beginGesture` is private and runs on the re-seed a suggestion itself triggers, which would dismiss the suggestion the instant it applied |+| Q48 | 2026-08-17 | Single-side verification fills the other slot with the hostname's stored rule (`.wholeTitle` when none) | Req 3.4 wording; it is the state the reader would actually save. Accepted consequence: a URL-only suggestion cannot verify on a taught site whose stored title rule already fails an entry |+| Q49 | 2026-08-17 | Req 3.1 amended: spans need not be ordered, and an overlapping optional span is dropped rather than failing the proposal | The assembler handles chapter-before-work already; a model that copies a chapter number also present in the work name should lose the chapter, not the whole suggestion |+| Q50 | 2026-08-17 | A memory warning clears `attempted` as well as `held` | Cost choice, not correctness: after a warning the run's cheap state is gone anyway and re-attempting a hostname on the next activation is bounded by the sweep depth and budget |+| Q51 | 2026-08-18 | `SuggestionCorpus.make(from:hostname:)` takes `ComposedEntryBasis` directly | The Core type is public with a public init, so no mirror type is needed; the suggester passes `contract.basis.entries` straight through |+| Q52 | 2026-08-18 | Captures sharing a `firstCapturedAt` tie-break on ascending `id.uuidString` | `UUID` is not `Comparable`; the string form is the only deterministic order available |+| Q53 | 2026-08-18 | `halved()` returns `SuggestionCorpus?`, nil once the context list is at the anchor-alone floor | Nil means "nothing left to shrink", so the halving retry loop terminates without a separate counter; 4→2→1→0 context entries then nil (anchor alone is the last corpus tried) matches Req 3.7 and the design's "at most three retries" |+| Q54 | 2026-08-18 | Pre-emption is a two-step ledger protocol: `start` returns `.preempt(hostname:)` without swapping the in-flight record; the coordinator cancels that task and awaits its termination — the cancelled attempt's own body settles `.cancelled` on the way out because it holds the elapsed clock (a second settle by the pre-emptor would double-charge the budget) — then calls `start` again | A pure value type can only enforce Req 5.11 ("awaits the cancelled task's termination") by refusing to hold two records; the contract is enforced by protocol, so task 10.2 must follow it. A settlement arriving for a record that `invalidate` or `memoryWarning` has voided is coerced to `.cancelled` (budget charged, nothing stored) so a stale result cannot resurrect a cleared hostname (Reqs 5.5, 5.6); a `start` against a voided record returns `.preempt`, never `.attach`, because attaching would hand the caller an answer `settle` is bound to discard |+| Q55 | 2026-08-18 | `.open` against an in-flight `.request` for a different hostname is refused, leaving the opened hostname unattempted | The design says an open never pre-empts a request; refusing is the only remaining option. Consequence: that editor opens without a suggestion and Req 5.7's "a computation started" does not hold until the next sweep |+| Q56 | 2026-08-18 | `RuleSuggestionModelClient` gains `isContextWindowOverflow(_:)` (default `false`); the Foundation client recognises `GenerationError.exceededContextWindowSize`, the stub its own canned overflow error | The suggester needs the halving trigger without importing `FoundationModels` or naming the concrete client; the error itself cannot be injected through the real client |+| Q57 | 2026-08-18 | The ledger owns `reconcile(against:)` and `trackedHostnames`; the coordinator's `reconcile()` only performs the library read | The fingerprint comparison is pure bookkeeping over state the ledger holds; keeping it there makes it unit-testable without a library |+| Q58 | 2026-08-18 | `RuleSuggestionEnvironment` carries `ProcessInfo.ThermalState`; `settle(.suggestion)` with an empty suggestion is stored as `.noSuggestion`; `start` requires a fingerprint (no default) | Referencing Foundation's enum is not a lookup and avoids a duplicate four-case type; the state table holds only "≥1 side"; a nil fingerprint would mismatch every reconcile and re-attempt the hostname forever |+| Q59 | 2026-08-18 | `RuleSuggestionCandidate` lives in `AsterismCore` beside the read; a Site with a `modeRaw` outside the closed set is omitted from the result | The read is on `LibraryProviding` (Core) and Core must not depend on `AsterismIntelligence`. Omission mirrors `sites()`; coercing to `.untaught` would make the site auto-eligible (against Q15), and absence reads as a fingerprint mismatch to `reconcile`, the conservative direction |+| Q60 | 2026-08-18 | `entryCount` is a raw `Entry` row count for the winning site, not a distinct-identity count; rows return sorted by hostname; an empty hostname set returns `[]` without taking the lock | A duplicate identity group can make the fingerprint over-count relative to the composed corpus, which only makes it over-sensitive, never stale. Sorted output makes comparisons stable; the empty short-circuit is `reconcile`'s common case |+| Q61 | 2026-08-18 | The candidate read costs one Site fetch plus a count and a `fetchLimit 1` fetch per hostname (2N), chosen over a single whole-store `Entry` pass | Per-hostname predicates keep `Site.entries` untraversed and stay proportional to the tracked set on `reconcile`; the sweep's `nil` read pays 2N once per foreground return, which is acceptable for the hostname counts a personal library reaches. Revisit if the sweep read shows up in profiles |+| Q62 | 2026-08-18 | `AsterismTests` links `AsterismIntelligence` alongside the app target; the share extension does not | The unit tests name `RuleSuggestion` and `StubRuleSuggestionModelClient` and the app does not re-export the module. Req 4.4 (extension never links it) holds and is checked by grep on the project file |+| Q63 | 2026-08-18 | `AttemptTimeout` carries `modelPhase: Duration` with no default | The abandoned attempt is the only party holding the elapsed clock and Req 5.2 charges the budget for it; no default so a construction cannot charge nothing by accident |+| Q64 | 2026-08-18 | The ledger's sweep state is a stop signal plus a single-instance guard keyed by a generation token (`beginSweep() -> Int?`, `isSweeping(generation:)`, `endSweep(generation:)`); the coordinator chains a new activation behind a sweep still terminating | One flag for both meanings let a re-activation during a stopped-but-live sweep pass the guard, refuse every candidate, and then end the older sweep from under it, silently skipping the activation's sweep (Req 5.1) |+| Q65 | 2026-08-18 | `.open` consults `ledger.refusesOpen(hostname:)` (attempted, in-flight `.request`, or both sides dismissed) before the coordinator pays for a candidate read | An editor open on an already-attempted hostname is the common case; refusing first avoids a 2N SwiftData read that returns nil anyway |+| Q66 | 2026-08-18 | A cancelled attempt's budget charge is measured from `beginAttempt` (includes the basis read), not from the first model request | The suggester does not surface its own elapsed on cancellation; the over-charge is conservative and bounded by one projection. `RuleSuggester.init(library:model:)` convenience exists but is main-actor isolated like every initializer under the app's default isolation — a `nonisolated` synchronous actor init is a Swift 6 error, measured, see the comment on the initializer |+| Q67 | 2026-08-18 | Req 6.4's "no suggestion" message also shows when a suggestion exists but neither side could be applied to the opened capture | From the reader's seat "no valid suggestion for either side" is what happened; a silent no-op after tapping Suggest is worse than the message |+| Q68 | 2026-08-18 | `suggestionReady` is set whenever a delivered *or held* suggestion leaves a side unapplied, not only on late arrival; it clears when the reader applies or clears | Req 5.9's condition is "at least one held suggestion exists"; without the held path a held suggestion on a taught site is invisible |+| Q69 | 2026-08-18 | `urlTouched` is set by every `updateURLRule` after `load()`; the URL editor's own re-seed after an apply does not publish, so it never marks the side touched | This is what makes Q47's edit detection work: only a real gesture dismisses the URL side |+| Q70 | 2026-08-18 | On an open with a held or delivered suggestion, `load()` projects once and `applySuggestion` invalidates and re-projects; the first projection is cancelled mid-flight | Ordering follows the design; the cost is one wasted projection per suggested open. Accepted for now — collapse the two if the first frame is seen to flicker |+| Q71 | 2026-08-18 | The suggestion marker is a combined `Label` leaf carrying the accessibility identifier, rendered in the URL disclosure content and on its collapsed header label; the title marker sits in the `storedTitleRuleNotice` slot | Req 2.1 wants the marker visible while the suggestion is applied and collapsing the disclosure is presentation-only; a container one level under a `DisclosureGroup` inherits the group's identifier, so only a leaf keeps `composed-url-suggested` queryable (see `docs/agent-notes/composed-teaching-ui.md`) |+| Q72 | 2026-08-18 | On open, a held suggestion suppresses the on-open computation only if it covers at least one *untaught* side of the hostname | Req 5.7 keys on untaught sides; a title-only hold on a title-taught / URL-untaught hostname must not swallow the URL request. Behaviourally inert today — `held ⟹ attempted` in the ledger and `suggestion(for:)` returns a hold for any origin — but the code states the requirement |+| Q73 | 2026-08-18 | The instructions carry a third worked example (opaque address, chapter only in the title) and tell the model to judge title and address separately, that a bare-number address names nothing, and that the domain is never `urlWorkIdentity` | First real capture (`Read Episode 1 - Apocalypse Online \| Tappytoon`, `…/en/chapters/393004944`) produced `chapterText ""` and title text in the URL fields, so Q44 discarded the answer; Example B taught only the opposite shape. Measured on this Mac: with the third example the same capture yields `workName "Apocalypse Online"`, `chapterText "Episode 1"` for one and for two captures |+| Q74 | 2026-08-18 | The instructions say a chapter is often just a number or decimal (12, 1.00, 4.3), frequently at the start of the title | `1.00 - The Wandering Inn` at `wanderinginn.com/2017/03/03/rw1-00/` came back with an empty `chapterText`; with the hint the model answers `workName "The Wandering Inn"`, `chapterText "1.00"`, which the assembler expresses as the chapter-first segment rule. Measured on this Mac |+| Q75 | 2026-08-18 | The Suggest control is a full-width secondary-style button with the sparkles icon at the head of the editor (after the title example, before the chip selector); busy is an overlay spinner, "ready" and the Req 6.4 notice sit directly under it | User feedback from the first device run: the footnote-sized text button beside Save did not read as a button and was not noticed. Supersedes the design's "beside the commit control" placement |+| Q76 | 2026-08-19 | Diagnostic logging across the pipeline via `RuleSuggestionLog` (subsystem `me.nore.ig.Asterism`, category `RuleSuggestion`): reasons, hostnames, outcomes and durations public; reader content public only under `#if DEBUG`, private otherwise. `describe(_:)` joins `isContextWindowOverflow` on the client protocol so the Foundation client can name the `GenerationError` case | Every drop point returned nil silently, which made the first device failure undiagnosable; Req 4.1 forbids telling the reader, not the log. Naming a framework error's case needs the framework's types, which only the client has |+| Q77 | 2026-08-19 | `ComposedTeachingViewModel.suggestionAutoApplyWindow` is an internal settable test seam defaulting to `RuleSuggestionBounds.autoApplyWindow`; production never assigns it | Lets the late-arrival tests pin the window boundary deterministically (`.zero`) without waiting out two seconds or widening the public initializer |+| Q78 | 2026-08-19 | The whole-store candidate read still pays a count and a `fetchLimit 1` fetch per site before the coordinator's eligibility filter; deferred, not fixed | Pre-push review finding: at hundreds of hostnames it is ~2 queries per site per foreground return. Fixing it well means either a batched `Entry` fetch or moving the Site-only half of eligibility into the repository, which changes the documented `nil = all sites` contract (Q59–Q61). The sweep now skips the read entirely once the budget is spent; revisit if the sweep read shows up in profiles |+| Q79 | 2026-08-19 | Verification keeps re-reading the hostname through `library.projectComposedTeaching` for each candidate set (up to three per attempt) rather than running the pure planner over the basis already in hand | Each re-read rebuilds the basis under the shared lock, so a capture landing mid-attempt is verified against; and the mock projection handler is the only seam the suggester tests use to inject a failing entry. Cost is bounded by three hostnames per activation. Revisit if the suggester's test seam is reshaped |+| Q80 | 2026-08-19 | The editor's suggestion state is derived, not latched: `titleSuggestionApplied`/`urlSuggestionApplied` are `appliedX != nil`, and `suggestionReady` is "a side the held suggestion offers that is neither applied nor dismissed" | Pre-push review: the latched `suggestionReady` went stale after a clear (against Q68), and a side dismissed in an earlier session lit the indicator. `AttemptStart.refuse` now carries the `RefusalReason` the ledger applied, and the background sweep skips hostnames with both sides dismissed (`isFullyDismissed`) |++## Decision 1: The model returns substrings, code locates them into spans++**Date**: 2026-08-17+**Status**: accepted++### Context++The requirements need character spans over the anchor capture's title and URL (Req 3.1). Foundation Models has no span or offset type, and Apple's guidance lists counting and arithmetic among things the 3B on-device model should not be asked to do. The output must be constrained to text that actually appears in the input.++### Decision++The `@Generable` output carries the *text* of each field (work name, chapter text, URL identity, URL sequence), described as "copied verbatim". `ProposalLocator` finds each string in the anchor and yields a span only when it occurs exactly once; anything else drops that field.++### Rationale++Constrained decoding guarantees the structure but not that the value is a substring; locating it in code makes Req 3.1 a deterministic check rather than a hope. Uniqueness is required because a repeated substring (a chapter number that also appears in the work name) has no single span.++### Alternatives Considered++- **Ask for character offsets**: the model would have to count; Apple advises against it and small models are unreliable at it - rejected.+- **`@Guide(.anyOf(candidates))` over enumerated substrings** (segments for titles, path components for URLs): the framework-native way to force a real substring, but titles need sub-segment phrases and URLs need within-component splits, so the candidate set is not enumerable without exploding the schema - rejected for the first cut; may return as a corroboration step.+- **Dynamic schema per hostname**: same enumeration problem plus per-call schema cost inside the token window - rejected.++### Consequences++**Positive:**+- Bad model output cannot produce an out-of-range span; it just produces no suggestion.+- The prompt is small and language-agnostic.++**Negative:**+- Repeated substrings lose the field even when the model meant the right occurrence.+- Whitespace/case drift in the model's copy is a miss; the locator is exact by design.++---++## Decision 2: Model-facing code lives in a new `AsterismIntelligence` package target++**Date**: 2026-08-17+**Status**: accepted++### Context++The share extension links `AsterismCore` and Req 4.4 forbids the extension linking `FoundationModels`. Title inference lives in the app target. Later AI items (character extraction, Q&A) will need the same availability gate and model client.++### Decision++Add a second library target `AsterismIntelligence` to `Packages/AsterismCore` (depends on `AsterismCore`, imports `FoundationModels`) holding the availability gate, model client protocol and implementation, proposal types, locator, ledger and bounds. The app target holds span→rule assembly, verification, the coordinator and the editor changes.++### Rationale++`import FoundationModels` builds in a SwiftPM library on the macOS 26 host, so the pure parts get `make test-core` coverage without a simulator; the extension keeps linking only `AsterismCore`; the next AI item has a home.++### Alternatives Considered++- **Everything in the app target**: simplest wiring - rejected because the ledger and locator would be simulator-only tests, and the availability/model-client pattern the plan says every later item reuses would be app-locked.+- **Everything in `AsterismCore`**: the plan's "pure planners live in Core" instinct - rejected because the extension would then link `FoundationModels`, and title inference would have to move to Core first.++### Consequences++**Positive:**+- Host-testable model client contract and state machine.+- Clear line: Core knows nothing about models.++**Negative:**+- One more target in `Package.swift`, and app code must import two modules.+- The span→rule assembly stays app-side, so an `AsterismIntelligence` test cannot exercise a full attempt end to end.++---
specs/rule-suggestion/design.md Added +271 / -0
diff --git a/specs/rule-suggestion/design.md b/specs/rule-suggestion/design.mdnew file mode 100644index 0000000..077b709--- /dev/null+++ b/specs/rule-suggestion/design.md@@ -0,0 +1,271 @@+# Design: Rule Suggestion++**Ticket:** T-2156 · **Requirements:** [`requirements.md`](requirements.md) · **Decisions:** [`decision_log.md`](decision_log.md)++## Overview++An on-device Foundation Models call proposes, for one hostname, the work-name+and chapter text of the newest capture's title and the identity and sequence+text of its raw URL. Existing editor machinery turns those substrings into a+title rule and a URL rule, the existing whole-hostname projection verifies+them, and the composed teaching editor seeds the survivors exactly as it seeds+a stored rule. Nothing new is persisted.++Two captures play different roles: the **anchor** (newest capture) is what the+model answers about; the **opened entry** is whichever capture the reader+opens the editor from, unknown at computation time. That is why the held+artefact is a rule, not spans (Q14).++## Architecture++### Placement++| Layer | Location | Holds | Linked by |+|---|---|---|---|+| `AsterismIntelligence` — new library **product and target** in `Packages/AsterismCore`, depends on `AsterismCore`, imports `FoundationModels`; test target `AsterismIntelligenceTests` | `Packages/AsterismCore/Sources/AsterismIntelligence/` | availability, model client protocol + FoundationModels implementation, proposal/corpus types, `RuleSuggestion` (held artefact), `CorpusFingerprint`, `ProposalLocator`, `RuleSuggestionLedger`, `RuleSuggestionBounds` | app only — never the extension (Req 4.4) |+| `AsterismCore` | existing | one new read on `LibraryProviding`/`LibraryRepository`: `ruleSuggestionCandidates(hostnames:)` | app + extension (extension surface unchanged) |+| App target | `Asterism/Asterism/RuleSuggestion/` + editor edits | span→rule assembly (needs `ComposedTeachingPresentation`), verification via `projectComposedTeaching`, `RuleSuggester`, `RuleSuggestionCoordinator`, editor seeding, marker/action UI | app |++`AsterismIntelligence` tests run under `make test-core` (host `swift test --no-parallel` builds `FoundationModels` on macOS 26 — verified). Tests never depend on the model being present; the model client is a protocol.++### Data flow++```mermaid+flowchart LR+  A[activation / editor open / Suggest action] --> C[RuleSuggestionCoordinator<br/>@MainActor]+  C -->|candidates + fingerprint| R[(LibraryRepository)]+  C -->|attempt Task| S[RuleSuggester actor]+  S -->|projectComposedTeaching wholeTitle/nil| R+  S -->|corpus| M[RuleSuggestionModelClient<br/>one LanguageModelSession]+  M -->|RuleProposal substrings| S+  S -->|locate + assemble| E[ComposedTeachingPresentation<br/>URLEditorState]+  S -->|verify: projectComposedTeaching| R+  S -->|RuleSuggestion?| C+  C -->|ledger| L[RuleSuggestionLedger]+  C -->|held / deliver| V[ComposedTeachingViewModel]+```++One attempt = one basis read, one model session, one to three verification+projections, all inside `RuleSuggester` (off the main actor). The coordinator+owns state, the attempt `Task`, and delivery.++### Integration points++| Where | Change |+|---|---|+| `AppLibraryModel.handleActivation()` (`AppLibraryModel.swift:375`) | after `refreshDiagnosesAndSnapshots()`, `Task { await suggestions.activationSweep() }` — never awaited inline (Req 5.1) |+| `AppLibraryModel.refreshDiagnosesAndSnapshots()` :571 | after `refreshAll()`, `await suggestions.reconcile()` — the single invalidation hook (Req 5.5). Every library mutation the app performs (teaching commit via `onMutation`, capture commit, drain, entry delete, articles toggle, curation, CloudKit arrivals via `handleSyncArrivals`) already funnels through this method |+| `ContentView.swift:202` | add `willResignActiveNotification` → `suggestions.resignActive()`, `didReceiveMemoryWarningNotification` → `suggestions.memoryWarning()` (Reqs 5.3, 5.6) |+| `AppLibraryModel.composedTeachingModel(for:context:)` :755, `(forHostname:)` :791 | inject `suggestions` into the view model |+| `ComposedTeachingViewModel.load()` :591 | after the retained-rule seeding block and `state = .ready`: record `appearedAt`, then `seedSuggestionIfAvailable()` (Req 1) |+| `ComposedTeachingViewModel.commitTitleEdit()` :1131 | title-side edit detection (Req 2.7) |+| `ComposedTeachingViewModel.updateURLRule(_:)` :811 (takes a `URLRuleOutcome`) | URL-side edit detection — the only entry the URL editor dispatches through, and not called on seed |+| `ComposedTeachingView` `titleChipSelector` (:115–190), confirm row (:73–75, `urlAnchoringPendingRow` :472) | marker, clear action, Suggest action, no-suggestion message |+| `LibraryProviding` + `LibraryRepository` | `ruleSuggestionCandidates(hostnames:)` (`LibraryRepository+RuleSuggestion.swift`); `MockLibraryProvider` gains a preset result |+| `ComposedTeachingPresentation.titleSelection`, `titleChips`, `tokenRanges`, `inferredTitleRule`, private `splits`, and the nested `TitleSegment`/`TitleChip`/`InferredTitleRule` types (their synthesized `==`) | annotate `nonisolated` (pure; the app target defaults to MainActor). `RuleSuggestionAssembler` is declared `nonisolated` |+| `URLEditorState` | one internal `mutating func setSplit(_ selection: URLTwoFieldSelection)` |+| `Package.swift` | new product `AsterismIntelligence`, target, test target; the app links both products |++### Attempt pipeline (`RuleSuggester.attempt(hostname:) async throws -> (suggestion: RuleSuggestion?, modelPhase: Duration)`)++1. **Basis.** `library.projectComposedTeaching(hostname:, request: .wholeTitle / nil)` — the same call `load()` makes; read `contract.basis`. It gives every entry's `captureTitle`, `rawURLString`, `firstCapturedAt`, `currentTitleRule`, `currentURLRule`. An articles-mode Site makes this call throw; the throw settles the attempt as *no suggestion* (the automatic path never starts one — candidates carry `siteMode`).+2. **Corpus.** Entries sorted by `firstCapturedAt` descending, ties by `id`; the first is the anchor; up to `captureSampleCount − 1` more are context. Deterministic (Req 3.7).+3. **Model.** `model.propose(corpus)` → `RuleProposal`. `exceededContextWindowSize` → halve the context list and retry within the same attempt (at most three retries for `captureSampleCount = 5`), floor = anchor alone. Any other `GenerationError` → no suggestion. Elapsed time from the first model request is measured here and returned with the result.+4. **Locate.** Title: `ProposalLocator.locate(text, in: anchorTitle)` per field — non-empty and exactly one occurrence, else absent. URL: the anchor URL is parsed with `RawURLRuleParser.parse` into `RawURLLexicalComponents`; each URL field is located in every path component's text and every query value; it must occur in exactly one component, exactly once, else absent. A URL span is therefore always `(component, component-relative Range<Int>)`; no raw-URL span is ever formed. Required: `workName` (title), `urlWorkIdentity` (URL). Optional: `chapterText`, `urlSequenceText`. Overlap between the required and optional span on one side drops the optional. Order is free (Q49).+   **Short-circuit:** if both `chapterText` and `urlSequenceText` are absent, settle as no suggestion — the projection would set `requiresUnsettledAcknowledgment` and Req 3.4 rejects it; skipping saves up to three projections.+5. **Assemble** (`RuleSuggestionAssembler`, pure):+   - Title: `titleSelection(in:workSpan:chapterSpan:)` → `titleChips` → `inferredTitleRule` → `InferredTitleRule` → `TitleRuleSuggestion(definition:trimPrefix:trimSuffix:)`. `nil` → no title side. A result equal to the untaught default (`.wholeTitle`, no trims) is kept only if a URL side exists; alone it would badge the default state.+   - URL: drive a fresh `URLEditorState`: `select(identityComponent)` into the work slot; if the sequence is in the same component, `setSplit(URLTwoFieldSelection(work:sequence:))`; if in another, `select(sequenceComponent)` into the sequence slot. Read `rule(in: components)`; only `.valid(definition)` is a URL side — `.pending`, `.unauthorable`, `.cleared` and any refusal drop it.+6. **Verify.** `projectComposedTeaching(hostname:, request:)`. Read `contract.outcome`; accept iff no entry has `titleFailure`/`urlFailure`, every entry's `workName` is non-nil and non-empty, and `requiresUnsettledAcknowledgment == false`; a throw is a fail. Sets in order: (a) both candidate sides; (b) title-only with `urlDefinition = basis.currentURLRule?.definition`; (c) URL-only with `titleDefinition/trims = basis.currentTitleRule ?? .wholeTitle`. First passing set wins; (b) and (c) run only if (a) fails or is not a pair (Req 3.4, Q27). Consequence, accepted: on a taught site whose stored title rule already fails on some entry, a URL-only suggestion cannot verify.+7. **Settle.** Return `(suggestion: RuleSuggestion?, modelPhase: Duration)`; both sides nil → `suggestion == nil`. `modelPhase` is what the coordinator charges to the budget and compares to the timeout (Reqs 5.2, 5.10).++**Timeout and cancellation.** Steps 3–6 run under a wrapper that cancels the work and throws `AttemptTimeout` after `attemptTimeout` measured from the model request; the wrapper checks between projections and the model call honours `Task.cancel`. The coordinator classifies: `AttemptTimeout` → attempted; `CancellationError` (system) → unattempted; both charge the budget (Reqs 5.2, 5.4, 5.10).++### Ledger (`RuleSuggestionLedger`, pure value type, host-tested)++| State | Key | Set by | Cleared by |+|---|---|---|---|+| `held: [hostname: RuleSuggestion]` | hostname | settled attempt with ≥1 side | `invalidate`, memory warning |+| `attempted: Set<String>` | hostname | attempt settled: success, no-suggestion, failure, timeout | `invalidate`, memory warning |+| `dismissed: [hostname: Set<Side>]` | hostname+side | reader edit / clear / differing save (Req 2.7) | Suggest action applying to that side (Req 6.5). **Not** by `invalidate` (Req 2.8) |+| `inFlight: InFlight(hostname, origin, voided)?` | — | start; `voided` set by `invalidate`/memory warning so a late settle is coerced to cancelled (Q54) | settle or cancel |+| `budgetSpent: Duration` | — | every settle or cancel, wall time from model request | never (per run) |+| `fingerprints: [hostname: CorpusFingerprint]` | hostname | attempt start (from the candidate row) | `invalidate` |+| `resignActive` | — | cancels an in-flight `.background` attempt (unattempted), withdraws sweep permission; `.open`/`.request` attempts continue | — |+| sweep state: `permittedSweep` (stop signal) + `runningSweep` (single-instance guard) keyed by a generation token — `beginSweep() -> Int?`, `isSweeping(generation:)`, `endSweep(generation:)` (Q64) | — | sweep start | sweep end (own generation only) / resign-active / pre-emption |++Transition rules:+- One in-flight attempt at a time (Req 5.11).+- `start(hostname, .background)` refused if attempted, budget exhausted, Low Power Mode, thermal ≥ `.serious`, not active, or another attempt is in flight.+- `start(hostname, .open)` refused if attempted (returns nothing, no model call — Req 5.7); otherwise pre-empts an in-flight `.background` or `.open` attempt for another hostname (Req 5.7, Q23); it does not pre-empt a `.request`.+- `start(hostname, .request)` starts regardless of `attempted` and pre-empts any in-flight attempt for another hostname (Req 6.6).+- Pre-emption awaits the cancelled task's termination before the new attempt starts (Req 5.11).+- Any start for the in-flight hostname attaches.+- Pre-emption ends the sweep for this activation; the next activation restarts it.+- Memory warning cancels an in-flight attempt (unattempted) and clears `held`, `attempted`, `fingerprints` (Q50).+- `CorpusFingerprint = (siteMode, entryCount, latestCaptureAt, titleRuleVersion?, urlRuleVersion?)`; the coordinator's `reconcile` calls `ruleSuggestionCandidates(hostnames:)` for the ledger's `trackedHostnames` (held/attempted/in-flight) and hands the rows to `ledger.reconcile(against:)`, which compares them to `fingerprints` and invalidates on mismatch (Q57). It is the *only* invalidation source besides memory warning; a change the app does not funnel through `refreshDiagnosesAndSnapshots` (none known) waits for the next reconcile.++### Coordinator (`RuleSuggestionCoordinator`, `@MainActor @Observable`, owned by `AppLibraryModel`)++```swift+var isModelAvailable: Bool                        // Req 4.1/6.1; re-read from the client on every activation (Q41)+func activationSweep() async                      // Req 5.1: all candidates → auto-eligible → sort → up to backgroundSweepDepth starts; single-instance; re-reads model availability first+func reconcile() async                            // Req 5.5; candidates for the tracked hostnames only+func resignActive()                               // Req 5.3+func memoryWarning()                              // Req 5.6+func held(for hostname: String) -> RuleSuggestion?+func isAttempted(_ hostname: String) -> Bool+var budgetSpent: Duration+func isDismissed(hostname: String, side: Side) -> Bool+func dismiss(hostname: String, side: Side)+func clearDismissal(hostname: String, side: Side)+func suggestion(for hostname: String, origin: Origin) async -> RuleSuggestion?+     // held → returned at once, no model call (Req 6.6); else start/attach per ledger; nil = settled with nothing+     // or refused. The attempt runs in a coordinator-owned Task, so cancelling the caller never cancels it.+```++`suggestion(for:origin:)` is the single delivery channel for on-open and on-request. Attempts are `Task`s held by the coordinator; the ledger never holds a `Task`.++### Candidate enumeration (Core)++`ruleSuggestionCandidates(hostnames: Set<String>?) async throws -> [RuleSuggestionCandidate]` on `LibraryProviding` (implemented by `LibraryRepository`, mocked by `MockLibraryProvider`), under a shared lock: `nil` = all `Site` rows, else only those hostnames; `SiteResolutionOrder.winnersByHostname`; per hostname a count fetch and a `fetchLimit 1` fetch sorted by `firstCapturedAt` descending (SwiftData has no aggregate max). Fields: `hostname, siteMode, titleRuleVersion: Int?, urlRuleVersion: Int?, entryCount, latestCaptureAt`. `Site.entries` is not traversed. The sweep passes `nil`; `reconcile` passes the tracked set.++## Components and Interfaces++### `AsterismIntelligence`++```swift+public enum RuleSuggestionBounds {           // every tunable, one home (Q11, Q17, Q22, Q24)+  public static let backgroundSweepDepth = 3+  public static let runTimeBudget: Duration = .seconds(60)+  public static let attemptTimeout: Duration = .seconds(10)+  public static let autoApplyWindow: Duration = .seconds(2)+  public static let captureSampleCount = 5+}++public struct SuggestionExample: Sendable { public var title: String; public var rawURL: String }+public struct SuggestionCorpus: Sendable {+  public var hostname: String+  public var anchor: SuggestionExample+  public var context: [SuggestionExample]  // ≤ captureSampleCount − 1+}++@Generable public struct RuleProposal {      // substrings, never offsets (Decision 1); empty = none (Q39)+  @Guide(description: "Exact text from the first title that names the work, copied verbatim")+  public var workName: String+  @Guide(description: "Exact text from the first title that identifies the chapter or part, verbatim, or empty if none")+  public var chapterText: String+  @Guide(description: "Exact text from the first URL that identifies the work, verbatim")+  public var urlWorkIdentity: String+  @Guide(description: "Exact text from the first URL that identifies the chapter or sequence, verbatim, or empty if none")+  public var urlSequenceText: String+}++public enum ModelAvailability: Sendable, Equatable { case available; case unavailable(reason: String) }+public protocol RuleSuggestionModelClient: Sendable {+  func availability() -> ModelAvailability+  func propose(_ corpus: SuggestionCorpus) async throws -> RuleProposal+  func isContextWindowOverflow(_ error: any Error) -> Bool   // default false (Q56)+  func describe(_ error: any Error) -> String                // loggable account; Foundation client names the GenerationError case (Q76)+}+public struct FoundationRuleSuggestionModelClient: RuleSuggestionModelClient   // one LanguageModelSession per call, greedy sampling+public struct StubRuleSuggestionModelClient: RuleSuggestionModelClient          // canned proposal / error / delay; Recorder call log + scripted results (Q54 fixes)++public enum ProposalLocator {+  public static func locate(_ text: String, in source: String) -> Range<Int>?  // Character offsets; unique & non-empty, else nil+}++public struct TitleRuleSuggestion: Sendable, Equatable { public var definition: PatternDefinition; public var trimPrefix: String?; public var trimSuffix: String? }+public struct RuleSuggestion: Sendable, Equatable {  // the held artefact (Q14)+  public var hostname: String+  public var title: TitleRuleSuggestion?+  public var url: URLRuleDefinition?+}+public struct CorpusFingerprint: Sendable, Equatable { entryCount, latestCaptureAt, titleRuleVersion, urlRuleVersion }+public enum Side: Sendable { case title, url }+public enum Origin: Sendable { case background, open, request }+public struct AttemptTimeout: Error { public var modelPhase: Duration }   // Q63+public struct RuleSuggestionLedger: Sendable, Equatable {+  // state and transitions above; plus: AttemptStart (.start/.attach/.preempt/.refuse), AttemptSettlement,+  // RuleSuggestionEnvironment (isActive, lowPower, thermal), refusesOpen(hostname:) (Q65),+  // beginSweep/isSweeping/endSweep (Q64), reconcile(against:) and trackedHostnames (Q57)+}+```++Model call: `LanguageModelSession(instructions:)` — fixed instructions (task, "copy text exactly as it appears", three worked examples — the third an opaque address whose chapter lives only in the title (Q73) — and the hint that a chapter is often a bare number or decimal (Q74)), prompt = numbered examples, anchor first; `GenerationOptions(sampling: .greedy)`; `respond(to:generating: RuleProposal.self)`. Property order is deliberate: work before chapter, title before URL.++### App target++```swift+actor RuleSuggester {+  init(library: any LibraryProviding, model: any RuleSuggestionModelClient)+  func attempt(hostname: String) async throws -> (suggestion: RuleSuggestion?, modelPhase: Duration)   // steps 1–7+}+enum RuleSuggestionAssembler {   // step 5, nonisolated, pure+  static func titleRule(anchorTitle: String, workSpan: Range<Int>, chapterSpan: Range<Int>?) -> TitleRuleSuggestion?+  static func urlRule(components: RawURLLexicalComponents, identity: (URLComponentSelection, Range<Int>), sequence: (URLComponentSelection, Range<Int>)?) -> URLRuleDefinition?+}+```++### `ComposedTeachingViewModel` additions++| Member | Purpose |+|---|---|+| `suggestions: RuleSuggestionCoordinator?` | injected; nil in tests that do not exercise it |+| `titleSuggestionApplied`, `urlSuggestionApplied: Bool` | drive the markers (Req 2.1) and `effectiveTitleRule` |+| `effectiveTitleRule` | returns the retained rule only when `!titleEdited && !titleSuggestionApplied` (Req 6.2 / Q33) |+| `appearedAt: ContinuousClock.Instant` | set when `state = .ready` in `load()`, before the initial preview |+| `urlTouched: Bool` | set by any `updateURLRule` after load; the URL side's "changed" for Reqs 5.8/5.9 (title uses `titleEdited`) |+| `applySuggestion(_:origin:)` | per side, for sides that are untaught (automatic) or any side (request), skipping dismissed sides on the automatic path. **Snapshot first, only on the not-applied → applied transition** (a re-apply keeps the original baseline): title `(subdividedSegments, titleChips, titleRoles, titleEdited, storedTitleRuleNotice)`, URL `(urlRuleDefinition, urlRuleStatus, disclosureState)`. Title: `seedTitleEditor(definition:trimPrefix:trimSuffix:)` — extracted from `seedTitleEditor(from: ComposedTitleRuleBasis)`, which becomes a wrapper — then require `selectedTitleRule` semantically equal to the suggestion — else restore the snapshot; on the automatic path the snapshot *is* the untaught initial state; suppress `storedTitleRuleNotice` (Q34). URL: headless `URLEditorState.seed(from:in:)` + `rule(in:)` on the opened entry's components; not semantically equal → restore; else set `urlRuleDefinition`/`urlRuleStatus` (the URL editor re-seeds via its `onChange`) and expand the disclosure. Then set the applied flags, on request clear the sides' dismissals and `storedTitleRuleNotice`, and `invalidatePreview(); await generatePreviewIfValid()` (Req 1.5) |+| `clearSuggestedSide(_:)` | Req 2.3: restore that side's pre-suggestion snapshot (untaught initial state on an untaught side, the retained rule on a taught side — Q42), clear the flag, dismiss |+| `commitTitleEdit()` | if `titleSuggestionApplied` → false + `dismiss(.title)` (Req 2.7); existing behaviour otherwise |+| `updateURLRule(_:)` | if `urlSuggestionApplied` and the incoming outcome's definition is not semantically equal to the suggested one (including nil) → false + `dismiss(.url)`; sets `urlTouched` |+| `seedSuggestionIfAvailable()` | `held(for:)` → apply automatic; else if auto-eligible and not attempted → child task `suggestion(for:origin: .open)`; on value: sides untouched and within `autoApplyWindow` of `appearedAt` are applied; any side of the result left unapplied (late, touched, or dismissed) sets `suggestionReady = true` (Reqs 5.7–5.9). Child task cancelled by the view model's `cancel()`/deinit; the attempt continues |+| `requestSuggestion()` | Req 6: `suggestionBusy = true`; `suggestion(for:origin: .request)`; value → `applySuggestion(origin: .request)`; nil → `suggestionUnavailableNotice` |+| `suggestionBusy` | true while *any* `suggestion(for:)` for this hostname is awaited, open or request (Req 6.3) |+| `suggestionReady`, `suggestionUnavailableNotice: String?` | view state |+| commit success path | for each side with a suggestion *held or applied* whose committed rule is not semantically equal → `dismiss` (Req 2.7). Equal → nothing (Req 2.6 is the planner's `unchanged`) |++Saving any rule reaches `reconcile` through `onMutation`, which invalidates the hostname; a still-untaught side is re-attempted on a later activation. Accepted cost (Q43).++### View++| Element | Baseline to match | Identifier |+|---|---|---|+| Suggested marker (per side) | `storedTitleRuleNotice` label at `ComposedTeachingView.swift:147-155` (`Label` caption, amber), symbol `sparkles`, "Suggested — review before saving"; VoiceOver "Suggested title rule" / "Suggested URL rule" | `composed-title-suggested`, `composed-url-suggested` |+| Clear action | inline `Button` in the marker's row, style of `composed-title-use-segments` (:187) | `composed-title-suggested-clear`, `composed-url-suggested-clear` |+| Suggest action | full-width `.constellationSecondary` button with `sparkles`, at the head of `editorContent` (after the title example, before the chip selector) (Q75); "Suggest rule"; shown only when `suggestions?.isModelAvailable == true` and `frozenBasis.siteMode != .articles` (Req 6.1); busy → disabled with an overlay `ProgressView`; ready → caption `Label` beneath it | `composed-suggest-row`, `composed-suggest`, `composed-suggest-busy`, `composed-suggest-ready` |+| No-suggestion message | directly under the Suggest button, `exclamationmark.circle`, "No suggestion available for this site" | `composed-suggest-unavailable` |++Containers gaining buttons carry `.accessibilityIdentifier` **and** `.accessibilityElement(children: .contain)` (`docs/agent-notes/composed-teaching-ui.md`).++## Error Handling++| Failure | Handling |+|---|---|+| Model `unavailable(reason)` | `isModelAvailable == false`; no attempts; action hidden; re-read on each activation |+| `exceededContextWindowSize` | halve context sample, retry, floor one |+| Other `GenerationError` | attempt settles as no suggestion; attempted |+| `AttemptTimeout` | cancel work, discard partial, attempted, charged |+| `CancellationError` | unattempted, charged |+| Projection throws / repository error | that candidate set fails; articles throw on step 1 = no suggestion |+| Library not ready | sweep and on-open skip silently; request shows the no-suggestion message |++Only Req 6.4's message ever reaches the reader.++## Testing Strategy++**`AsterismIntelligence` (host, `make test-core`)**+- `ProposalLocator`: unique / absent / duplicate / empty, Unicode and CJK; parameterised property: for a random source and a random slice, `locate(slice, in: source)` returns that slice's range whenever the slice is unique.+- `RuleSuggestionLedger`: every transition — single-flight; attach vs start per origin; `.open` refused when attempted; `.request` starts when attempted; pre-emption regardless of origin ends the sweep; budget blocks background only; gates; timeout → attempted, cancel → unattempted, both charged; dismissed per side survives invalidate; invalidate clears held/attempted/fingerprint; memory warning; fingerprint mismatch.+- `FoundationRuleSuggestionModelClient`: instructions/prompt construction against a captured string; one live test guarded on `SystemLanguageModel.default.isAvailable`, `withKnownIssue` when absent.+- Corpus selection: ordering incl. tie-break, cap, halving, at-least-two.++**App unit tests (`make test-quick`; `ComposedTeachingViewModelTests` pattern with `MockLibraryProvider` + `StubRuleSuggestionModelClient`)**+- `RuleSuggester` on fixture bases: title and URL assembly; both-then-singles with the stored other side; unauthorable dropped; straddling URL span dropped; overlap drops the optional field; no-chapter-no-sequence short-circuit; default-title-only dropped; articles throw → nil; a failing entry fails the set.+- View model: seeding untaught sides only; retained wins on taught; markers; edit → marker off + dismissed (title via `commitTitleEdit`, URL via `updateURLRule`); re-seed does not dismiss; clear restores snapshot; identical save no new version; differing save dismisses; `effectiveTitleRule` after apply on taught side; cannot-depict → snapshot restored, no `storedTitleRuleNotice`; late arrival → `suggestionReady`; request → busy → applied / message; request applies to a dismissed side and clears it; preview regenerated after apply.+- Coordinator: sweep depth and single instance; `reconcile` invalidation from a changed candidate row; resign-active cancel leaves unattempted; `suggestion(for:)` returns held without a model call.++**UI tests (`ComposedSurfaceUITests`)** — stub client via launch environment `ASTERISM_UI_TEST_SUGGESTION=canned`: `composed-title-suggested` visible on open, chip tap removes it, `composed-suggest` present; `=unavailable`: none of the identifiers exist.++**Latency spike (task, not test)** — before the `RuleSuggestionBounds` values are finalised, read the model-phase and outcome lines the coordinator logs (`subsystem:me.nore.ig.Asterism category:RuleSuggestion`) from a `Personal` install used normally: first attempt after launch is cold, later ones warm; record in `implementation.md`. Installing on the physical device needs explicit approval at the time.
specs/rule-suggestion/implementation.md Added +43 / -0
diff --git a/specs/rule-suggestion/implementation.md b/specs/rule-suggestion/implementation.mdnew file mode 100644index 0000000..d2ed7e5--- /dev/null+++ b/specs/rule-suggestion/implementation.md@@ -0,0 +1,43 @@+# Rule Suggestion — Implementation Notes++## Phase 1: AsterismIntelligence package (2026-08-18)++- Package target, tests and the five phase-1 types landed in `ed92255`; review fixes (Q54–Q58) followed in `a1dcbc0`.+- `make test-core` runs the whole `AsterismCore` package with `--no-parallel`, so the new `AsterismIntelligenceTests` target is included without a Makefile change. `AsterismIntelligenceTests` alone: ProposalLocator 10, SuggestionCorpus 8, RuleSuggestionLedger 39+, FoundationRuleSuggestionModelClient 7.+- `Asterism.xcodeproj/project.pbxproj` has no `AsterismIntelligence` or `FoundationModels` reference: neither the app nor the extension links the product yet. Task 13 must add it to the app target only (Req 4.4).++### Latency observation (feeds the prerequisites latency spike)++The single live model test (`FoundationRuleSuggestionModelClientTests`, two-example corpus, `Personal`-equivalent release settings not applied — this is a debug host run) took **~3 s warm** on the implementing run and **~15 s cold** on the review run, on the same Mac. Cold is 50% over `attemptTimeout` (10 s) and a quarter of `runTimeBudget` (60 s). The design measures the timeout "from the model request", and one `LanguageModelSession` per `propose()` puts model load inside every attempt. If the phone behaves similarly, cold attempts time out. The prerequisites already schedule a device latency spike after task 9.2; these numbers say it should not be skipped, and the spike should separate session warm-up from `respond` time before the five bounds are finalised.++### Open for later phases++- Task 10.1's "returns held without a model call" and any halve-then-succeed test use the stub's call log and scripted results (added in the phase-1 review fix).+- Sweep depth (`backgroundSweepDepth`) is enforced by the coordinator, not the ledger (design.md coordinator section).++## Phase 2: Core candidate read (2026-08-18)++- `ruleSuggestionCandidates(hostnames:)` on `LibraryProviding`/`LibraryRepository` (`LibraryRepository+RuleSuggestion.swift`), `RuleSuggestionCandidate` in Core, `MockLibraryProvider` preset. Landed in `c7fdde9`; review-driven test hardening followed. Decisions Q59–Q61.+- Open observations from review, not acted on: the fingerprint cannot see a corpus change that leaves entry count, latest `firstCapturedAt` and rule versions unchanged (no known path rewrites `captureTitle` in place — repeat-share only touches note/rating/lastSharedAt/modifiedAt); and `latestCaptureAt` reads `firstCapturedAt`, so re-sharing an existing URL does not re-prioritise the hostname in the sweep. Both are consistent with the design's recency definition; note them if either turns out to matter in use.++## Phase 3: App assembly, suggester, coordinator (2026-08-18)++- Editor seams (task 7), `RuleSuggestionAssembler`, `RuleSuggester`, `RuleSuggestionCoordinator` landed in `ead0a17`, `f8917ce`, `ca34f91`; review fixes in `4c213fe`. Decisions Q62–Q66; Q53/Q54 corrected. `AsterismIntelligence` is now linked by the app target and `AsterismTests` only.+- Q54 in practice: the cancelled attempt's own body settles `.cancelled`; `cancelInFlight()` awaits `task.value` on the main actor, so `inFlight = nil` happens-before the retry `start`. Voided settlements are discarded by the ledger.+- Coordinator tests written in the review pass gate on an explicit continuation in `StubSuggester` (`isGated`/`openGate()`/`waitForAttempts(_:)`); the earlier tests still use short sleeps — convert them if they flake.+- Not tested: the `held` re-read inside the start loop (no deterministic path into that window from the current API).++## Phase 4: Editor integration (2026-08-18)++- View-model behaviour, marker/clear/Suggest UI, `AppLibraryModel`/`ContentView` wiring and the UI-test stub landed in `cdeff76`, `824acb2`, `9730481` (task 13's wiring was committed with `824acb2`; `9730481` only marks the task), `ae80b94`; review fixes in `06d482b`. Decisions Q67–Q72.+- Verified: `make test-core`, `make test-quick` (732), `make test-ui` (84 UI tests, including `testCannedSuggestionSeedsTheEditorAndYieldsToAnEdit` and `testModelUnavailableLeavesTheSurfaceUnchanged`). `AsterismIntelligence` is linked by the app target and `AsterismTests` only.+- The canned UI-test proposal (`workName "Real Title"`, `chapterText "TtH"`, `urlWorkIdentity "Story-28614-94"`) is tuned to the `seeded-composed` fixture; `ASTERISM_UI_TEST_SUGGESTION` is read only under `#if DEBUG || ASTERISM_PERFORMANCE_TESTING`.+- Known narrow gaps (not fixed): `suggestionUnavailableNotice` persists until the next request rather than clearing on the reader's next edit; the late-arrival tests pin the window boundary with `suggestionAutoApplyWindow = .zero`, three residual 30 ms sleeps remain on preview settling.+- Still outstanding from `prerequisites.md`: the on-device latency spike (needs the user's explicit approval at the moment of running).++## First device run (2026-08-18)++- Development install on the phone: model available, Suggest returned "no suggestion" ~2 s after tapping, for a single capture `Read Episode 1 - Apocalypse Online | Tappytoon` at `https://www.tappytoon.com/en/chapters/393004944?`.+- Reproduced on the Mac against the live model: the proposal came back with an empty `chapterText` and title text in the URL fields, so the Q44 short-circuit (no chapter, no sequence) dropped it. Fixed by prompt (Q73); the same capture now yields the right title answer. The URL fields for an opaque address are still noisy (domain offered as identity) but never assemble, so they cost nothing.+- Diagnostic logging was added across the pipeline in `f9b62f0`: Console filter `subsystem:me.nore.ig.Asterism category:RuleSuggestion`. Reader content is readable in Development builds only.+- Local model latency for one proposal on this Mac: 5–10 s (the first call in a process is the slow one). The 2 s the phone showed for the failing answer suggests the phone's model is faster than the Mac's cold path, not that the timeout fired.
specs/rule-suggestion/prerequisites.md Added +15 / -0
diff --git a/specs/rule-suggestion/prerequisites.md b/specs/rule-suggestion/prerequisites.mdnew file mode 100644index 0000000..d8d7665--- /dev/null+++ b/specs/rule-suggestion/prerequisites.md@@ -0,0 +1,15 @@+# Prerequisites for Rule Suggestion++These tasks must be completed by the user before or during implementation.++## Before Starting++- [x] Confirm the host Mac used for `make test-core` has Apple Intelligence enabled, so the one live model test in task 5.1 can pass. Verified 2026-08-17 on this Mac: `SystemLanguageModel.default.availability == .available`, `contextSize == 4096`. Everything runs on this Mac.++## After Task 9.2 (any time — does not block implementation)++- [ ] **Latency spike to tune `RuleSuggestionBounds`.** Implementation proceeds throughout on the provisional constants (`backgroundSweepDepth 3`, `runTimeBudget 60 s`, `attemptTimeout 10 s`, `autoApplyWindow 2 s`, `captureSampleCount 5`), all in one enum. Once `RuleSuggester.attempt(hostname:)` exists (task 9.2), measure its wall time warm and cold for a five-capture hostname on the `Personal` configuration and revisit the five values (Q11, Q17, Q22). This can be done at the end of the task list, or after the feature has been in use. It runs on the physical iPhone and therefore needs explicit approval at the moment of running (see `CLAUDE.md`); the agent must ask, not assume, and must not set `CONFIRM_DEVICE_RUN`. Record the numbers in `specs/rule-suggestion/implementation.md`. **Update 2026-08-18:** the coordinator now logs the model phase and outcome of every settle (Console filter `subsystem:me.nore.ig.Asterism category:RuleSuggestion`), so the spike is read off the log of a `Personal` install used normally — first attempt after launch is cold, later ones warm — rather than from an instrumented device test run. Installing on the phone still needs approval at the time; the `Personal` build with the logging was installed 2026-08-18 with the user's approval, measurements pending.++## Before Testing++- [x] Nothing further: the UI-test scenarios in task 14 run on the simulator (`make test-ui`) with the stub client injected via `ASTERISM_UI_TEST_SUGGESTION`; Apple Intelligence is not involved.
specs/rule-suggestion/requirements.md Added +135 / -0
diff --git a/specs/rule-suggestion/requirements.md b/specs/rule-suggestion/requirements.mdnew file mode 100644index 0000000..3324131--- /dev/null+++ b/specs/rule-suggestion/requirements.md@@ -0,0 +1,135 @@+# Requirements: Rule Suggestion++**Ticket:** T-2156+**Plan item:** 3 of [`docs/asterism-v2-plan.md`](../../docs/asterism-v2-plan.md)++## Introduction++Teaching a site today means authoring its title rule and URL rule by hand in+the composed teaching editor. This feature has the on-device model propose+those rules from the site's existing captures, so that the editor opens+pre-filled and the reader only reviews and confirms. A suggestion is shown+only if it parses every capture on that hostname, is written only when the+reader saves it, and its absence — because the model is unavailable, the+proposal failed validation, or it has not finished — leaves the editor exactly+as it is today.++## Definitions++- **App run** — one process lifetime. Every piece of state this feature keeps lives for at most one app run and is never written to disk.+- **Activation** — the app moving to the foreground with the library ready (the same hook the pending-capture drain uses).+- **Automatic path** — a suggestion prefilled without the reader asking: on editor open (Requirement 1) or on late arrival (Requirement 5).+- **Auto-eligible hostname** — a hostname whose resolved Site is not in articles mode, has at least one capture, and lacks a stored title rule or a stored URL rule.+- **Suggestion** — a title rule and/or a URL rule for a hostname, derived from spans the on-device model returned over that hostname's capture titles or raw URLs, and verified per Requirement 3. Both sides are model-derived; deterministic URL comparison across captures may inform or corroborate a proposal but never produces a suggestion on its own.+- **Held** — computed and retained in memory for the current app run.+- **Semantically equal** — equal under the library's existing rule comparator, the same test the editor already uses to decide whether a save writes a new rule version.+- **Editor appearance** — the instant the composed teaching editor's initial load completes and it is interactive.+- **Untaught initial state** — the editor state for a side when the hostname has no stored rule for it and no suggestion is applied: whole-title selection on the title side, no selection on the URL side.++## Non-Goals++- Suggesting or showing a rule inside the share extension — teaching stays in the app, and inference stays off the capture path.+- Proposing that a site is not serialised fiction (the articles exit) — the model proposes rules, never the exit; sites already in articles mode get no suggestion on any path.+- Persisting suggestions, attempts, budgets, or dismissals across launches.+- A Settings toggle to disable suggestions.+- Prefilling a suggestion over a retained rule — a taught site's stored rule wins on open; a suggestion there is offered only on the reader's request.+- A model-free URL suggestion from URL diffing alone — the parked "URL-identity auto-suggestion via URL diffing" item is folded in only as corroborating evidence.+- Suggesting canonical-URL identity opt-in, chapter-title rules, or any field the composed editor does not already author.+- Any schema change, migration, or backup format change.+- Changing how the editor previews, validates, or commits a hand-authored rule.++---++### 1. A Suggestion Is Offered for an Untaught Site++**User Story:** As a reader, I want the teaching editor to open with a proposed title rule and URL rule for a site I have not taught, so that I review a rule instead of authoring one.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHEN the composed teaching editor opens for an auto-eligible hostname with no stored title rule, a held title suggestion exists, and the hostname's title side is not dismissed, the title editor SHALL open with that suggestion applied as the current selection  +2. <a name="1.2"></a>WHEN the composed teaching editor opens for an auto-eligible hostname with no stored URL rule, a held URL suggestion exists, and the hostname's URL side is not dismissed, the URL editor SHALL open with that suggestion applied as the current selection  +3. <a name="1.3"></a>The title suggestion and the URL suggestion SHALL be offered independently: a hostname can have a held suggestion for one side and none for the other, and each side is applied, marked, edited and dismissed on its own  +4. <a name="1.4"></a>A held suggestion SHALL be seeded onto the entry the editor opened from as a rule, with the same cannot-depict fallback a stored rule has; IF the rule cannot be depicted on that entry's title or URL, THEN on the automatic path that side SHALL open in its untaught initial state with no marker, no message, and no stored-rule notice, and on request that side SHALL be left unchanged  +5. <a name="1.5"></a>WHEN a suggestion is applied, the preview SHALL show the projected result for the whole hostname exactly as it does for a hand-authored selection  +6. <a name="1.6"></a>The automatic path SHALL NOT compute or apply a suggestion for a hostname that is not auto-eligible  ++---++### 2. The Reader Sees What Was Suggested and Stays in Control++**User Story:** As a reader, I want to know which parts of the editor were filled by a suggestion and be able to change or ignore them, so that a machine proposal never becomes a rule without my judgement.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHILE a suggestion is applied and unedited on a side, that side SHALL carry a visible marker identifying it as suggested, and the marker SHALL be exposed to VoiceOver with a label naming the side  +2. <a name="2.2"></a>WHEN the reader changes the selection on a suggested side, the marker for that side SHALL be removed and the editor SHALL show the same controls, preview, and save enablement it shows for a hand-authored selection with the same content  +3. <a name="2.3"></a>WHILE a suggestion is applied on a side, the editor SHALL offer one action that returns that side to the state it had before the suggestion was applied — its untaught initial state on an untaught side, the retained rule on a taught side  +4. <a name="2.4"></a>Saving SHALL use the editor's existing save action; there SHALL be no separate accept step for a suggestion  +5. <a name="2.5"></a>Nothing SHALL be written to the library because a suggestion was computed, applied, or shown — a rule is stored only when the reader saves  +6. <a name="2.6"></a>WHEN the reader saves a suggestion that is semantically equal to the hostname's stored rule for that side, no new rule version SHALL be written for that side (the editor's existing behaviour, preserved for suggested selections)  +7. <a name="2.7"></a>WHEN the reader changes the selection on a suggested side by direct editing, clears it under [2.3](#2.3), or saves a rule on a side for which a suggestion is held or applied that is not semantically equal to it, that side of that hostname SHALL be marked dismissed for the rest of the app run; an on-request application under [6.2](#6.2) is not a change  +8. <a name="2.8"></a>A dismissed side SHALL NOT be prefilled by the automatic path; it SHALL still receive a suggestion on request under Requirement 6. Cancelling the editor without saving SHALL NOT dismiss either side. A dismissal SHALL survive changes to the hostname's captures and stored rules  ++---++### 3. Only Verified Suggestions Are Shown++**User Story:** As a reader, I want a suggestion to be one that actually parses my captures, so that a wrong proposal never reaches the editor and I never have to distrust what it shows.++**Acceptance Criteria:**++1. <a name="3.1"></a>A model proposal SHALL be accepted only as spans over the exact capture title or raw URL it was given, each span in bounds and non-empty; WHEN the optional span on a side overlaps the required one, the optional SHALL be discarded; a proposal whose required text is absent or ambiguous in the given text SHALL be discarded for that side  +2. <a name="3.2"></a>A title suggestion SHALL be shown only if the rule it yields is structurally valid, yields a non-empty work name, and applies to every capture title on the hostname without error  +3. <a name="3.3"></a>A URL suggestion SHALL be shown only if the rule it yields is structurally valid and resolves exactly once on every raw URL on the hostname  +4. <a name="3.4"></a>A suggestion SHALL be shown only if the whole-hostname projection — the same projection the editor's preview uses — with every suggestion that will be applied together (both sides when both exist, otherwise the one side with the hostname's stored rule or none as the other) reports no failure and does not require unsettled-identity acknowledgment; a pair that fails together SHALL be reduced to whichever single side passes alone, or to none  +5. <a name="3.5"></a>WHEN a held suggestion is seeded onto the opened entry, the rule the editor derives from the resulting selection SHALL be semantically equal to the verified suggestion; IF it is not, or the selection is unauthorable, THEN that side SHALL be treated as having no valid suggestion — [1.4](#1.4)'s cannot-depict outcome on the automatic path, unchanged on request  +6. <a name="3.6"></a>WHEN a proposal fails any check in [3.1](#3.1)–[3.4](#3.4), it SHALL be discarded and that side SHALL open in its untaught initial state (or, on a taught site, with the retained rule) with no marker, no message, and no stored-rule notice  +7. <a name="3.7"></a>The model SHALL be given a bounded, deterministically chosen set of the hostname's captures — the most recent up to a fixed count (a single named constant), at least two when the hostname has two — and a hostname whose input would exceed the model's context window SHALL be attempted with fewer captures, dropped oldest first, not skipped, down to a minimum of one  ++---++### 4. Model Unavailability Is a Non-Event++**User Story:** As a reader on a device or in a state without the on-device model, I want the app to behave exactly as it does today, so that the feature's absence costs me nothing.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHILE the on-device model reports itself unavailable for any reason, the system SHALL start no attempt, SHALL show no message about it, and SHALL NOT show the on-request action; a suggestion already applied when availability changes is unaffected  +2. <a name="4.2"></a>WHEN an automatic-path attempt fails, times out, or is cancelled, no marker, message, or editor state change SHALL result from it  +3. <a name="4.3"></a>All inference SHALL run on the device; no capture title, URL, or other library content SHALL leave the device for suggestion purposes  +4. <a name="4.4"></a>The share extension target SHALL contain no code from this feature and SHALL NOT link the on-device model framework  ++---++### 5. Suggestions Are Computed Ahead of the Editor++**User Story:** As a reader, I want the suggestion to be ready when I open the editor, so that teaching a site does not begin with a wait.++**Acceptance Criteria:**++1. <a name="5.1"></a>WHEN the app activates, the system SHALL compute suggestions in the background, with no model work on the main actor, for at most 3 auto-eligible hostnames not yet attempted this run, ordered by most recent capture first  +2. <a name="5.2"></a>Every attempt this run — background, on-open, on-request, abandoned or cancelled — SHALL spend wall-clock time from model request issue to settlement against one cumulative budget; once it is exhausted no new background attempt SHALL start, an attempt already in flight SHALL NOT be aborted for it, and on-open and on-request attempts still may start. The budget's initial value is 60 seconds and SHALL be a single named constant so it can be revised after measurement  +3. <a name="5.3"></a>The background sweep SHALL NOT start while the device is in Low Power Mode or its thermal state is serious or critical, SHALL start no further attempt once either condition begins mid-sweep, and SHALL stop when the app resigns active; on-open and on-request computations are not subject to the Low Power or thermal gate and SHALL continue across resign-active, bounded by [5.10](#5.10)  +4. <a name="5.4"></a>An attempt that settles — succeeded, failed, or timed out — marks the hostname attempted for the background sweep until it is invalidated under [5.5](#5.5), regardless of which sides produced a suggestion; an attempt cancelled by the system under [5.3](#5.3), [5.5](#5.5), [5.7](#5.7), [6.6](#6.6), or by memory pressure SHALL leave the hostname unattempted  +5. <a name="5.5"></a>WHEN the set of captures on a hostname changes for any reason, or its Site mode or stored rules change, any held suggestion for that hostname SHALL be discarded, any in-flight attempt for it SHALL be cancelled, and the hostname SHALL become attemptable again; a suggestion already applied and unedited in an open editor for that hostname is left as it is, since the editor's own reload handles corpus changes  +6. <a name="5.6"></a>WHEN the system delivers a memory warning, held suggestions SHALL be dropped and their hostnames SHALL become attemptable again  +7. <a name="5.7"></a>WHEN the editor opens for an auto-eligible hostname with no held suggestion for any of its untaught sides and no attempt this run: IF a computation for that hostname is in progress, THEN its result SHALL be delivered to the open editor; otherwise any in-flight computation for another hostname — background or continued under this criterion — SHALL be cancelled and a computation for this hostname started. IF the editor closes before that computation completes, THEN it SHALL continue and its result SHALL be held  +8. <a name="5.8"></a>WHEN a computation for the open editor's hostname completes within the auto-apply window after editor appearance — initially 2 seconds, a single named constant — the suggestion for each side the reader has not changed SHALL be applied as in [1.1](#1.1)/[1.2](#1.2)  +9. <a name="5.9"></a>WHEN a computation for the open editor's hostname completes after the auto-apply window, its result SHALL be held and SHALL NOT change the editor; for a side the reader has already changed, that side's suggestion SHALL likewise be held. IF at least one held suggestion exists for the hostname, THEN the on-request action in Requirement 6 SHALL indicate that a suggestion is ready  +10. <a name="5.10"></a>A single suggestion attempt for one hostname — model request through whole-hostname verification — SHALL be abandoned, and any partial result discarded, if it has not settled within 10 seconds of the model request being issued; the number SHALL be a single named constant  +11. <a name="5.11"></a>At most one suggestion computation SHALL run at a time  ++---++### 6. A Suggestion on Request++**User Story:** As a reader re-teaching a site whose rule is wrong, or whose suggestion arrived late, I want to ask for a suggestion, so that fixing a bad rule is as easy as authoring one on a fresh site.++**Acceptance Criteria:**++1. <a name="6.1"></a>WHILE the on-device model is available and the hostname's Site is not in articles mode, the composed teaching editor SHALL offer an action that requests a suggestion for the current hostname  +2. <a name="6.2"></a>WHEN the action in [6.1](#6.1) is used, a valid suggestion SHALL replace the current selection on each side for which one exists and be marked as in [2.1](#2.1); on a taught side the commit SHALL store the suggested rule, not the retained one; a side with no valid suggestion SHALL be left unchanged  +3. <a name="6.3"></a>WHILE any computation for the current hostname is in progress, the action SHALL show a busy state and SHALL NOT be triggerable  +4. <a name="6.4"></a>WHEN a requested computation completes and no valid suggestion exists for either side, the editor SHALL show a message stating that no suggestion is available, in the same place and style the editor uses for other transient status, and SHALL leave both sides unchanged  +5. <a name="6.5"></a>Using the action SHALL apply to a dismissed side; WHEN a suggestion is applied to it, that side's dismissal SHALL be cleared, re-set only by a further reader action under [2.7](#2.7)  +6. <a name="6.6"></a>Using the action SHALL apply a held suggestion when one exists without a new model call; otherwise, IF a computation for this hostname is in flight, THEN it SHALL attach to it; otherwise it SHALL cancel any in-flight computation for another hostname and start one for this hostname regardless of prior attempts this run, and SHALL treat timeout, failure, or cancellation of that computation as "no valid suggestion" under [6.4](#6.4)  
specs/rule-suggestion/tasks.md Added +197 / -0
diff --git a/specs/rule-suggestion/tasks.md b/specs/rule-suggestion/tasks.mdnew file mode 100644index 0000000..66ee70c--- /dev/null+++ b/specs/rule-suggestion/tasks.md@@ -0,0 +1,197 @@+---+references:+    - specs/rule-suggestion/requirements.md+    - specs/rule-suggestion/design.md+    - specs/rule-suggestion/decision_log.md+---+# Rule Suggestion Tasks++## AsterismIntelligence package++- [x] 1. Add the AsterismIntelligence product, target and test target and define its shared types <!-- id:m0d5oyn -->+  - Packages/AsterismCore/Package.swift: new .library product `AsterismIntelligence`, target depending on `AsterismCore`, test target `AsterismIntelligenceTests`; swift-tools 6.2, Swift 6 mode, same platforms+  - Sources/AsterismIntelligence/: RuleSuggestionBounds (backgroundSweepDepth 3, runTimeBudget 60 s, attemptTimeout 10 s, autoApplyWindow 2 s, captureSampleCount 5 — each with a doc comment naming its Q), Side, Origin, AttemptTimeout, ModelAvailability, SuggestionExample, SuggestionCorpus, TitleRuleSuggestion, RuleSuggestion, CorpusFingerprint (siteMode, entryCount, latestCaptureAt, titleRuleVersion, urlRuleVersion), RuleProposal (@Generable, four String fields, empty = none, declared order work→chapter→urlWork→urlSequence), RuleSuggestionModelClient protocol, StubRuleSuggestionModelClient (canned proposal / error / delay)+  - The extension target must not gain this product; verify with `make verify-identity`-style grep or the build settings, and `make test-core` must build the new target+  - No FoundationModels usage yet beyond the @Generable macro+  - Stream: 1+  - Requirements: [4.3](requirements.md#4.3), [4.4](requirements.md#4.4)+  - References: specs/rule-suggestion/design.md, Packages/AsterismCore/Package.swift++- [x] 2. ProposalLocator <!-- id:m0d5oyo -->+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1)+  - [x] 2.1. Write unit and property tests for ProposalLocator: unique / absent / duplicate / empty; Unicode and CJK; random-slice round-trip <!-- id:m0d5oyp -->+    - Tests/AsterismIntelligenceTests/ProposalLocatorTests.swift+    - Offsets are Range<Int> over Characters (match TitleSegment.range); cover combining marks and CJK+    - Property case: parameterised swift-testing over generated (source, slice) pairs — expect the slice's range whenever the slice occurs once, nil when it occurs twice or is empty+    - Blocked-by: m0d5oyn (Add the AsterismIntelligence product, target and test target and define its shared types)+    - Stream: 1+    - Requirements: [3.1](requirements.md#3.1)+  - [x] 2.2. Implement ProposalLocator.locate(_:in:) to pass the tests <!-- id:m0d5oyq -->+    - Sources/AsterismIntelligence/ProposalLocator.swift; unique-occurrence search over Characters+    - Blocked-by: m0d5oyp (Write unit and property tests for ProposalLocator: unique / absent / duplicate / empty; Unicode and CJK; random-slice round-trip)+    - Stream: 1+    - Requirements: [3.1](requirements.md#3.1)++- [x] 3. Corpus selection <!-- id:m0d5oyr -->+  - Stream: 1+  - Requirements: [3.7](requirements.md#3.7)+  - [x] 3.1. Write tests for SuggestionCorpus construction: recency order with id tie-break; captureSampleCount cap; at-least-two; halving down to the anchor <!-- id:m0d5oys -->+    - Input is a value type mirroring ComposedEntryBasis fields the package can see (title, rawURL, firstCapturedAt, id) — do not depend on AsterismCore's ComposedEntryBasis being public if it is not; add a small public init if needed+    - halved(): context list halves each call, floor is anchor alone; at-least-two when two entries exist+    - Blocked-by: m0d5oyn (Add the AsterismIntelligence product, target and test target and define its shared types)+    - Stream: 1+    - Requirements: [3.7](requirements.md#3.7)+  - [x] 3.2. Implement SuggestionCorpus.make(from:) and .halved() to pass the tests <!-- id:m0d5oyt -->+    - Sources/AsterismIntelligence/SuggestionCorpus.swift+    - Blocked-by: m0d5oys (Write tests for SuggestionCorpus construction: recency order with id tie-break; captureSampleCount cap; at-least-two; halving down to the anchor)+    - Stream: 1+    - Requirements: [3.7](requirements.md#3.7)++- [x] 4. RuleSuggestionLedger <!-- id:m0d5oyu -->+  - Stream: 1+  - Requirements: [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.11](requirements.md#5.11), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)+  - [x] 4.1. Write tests for every ledger transition in the design's state table and rules <!-- id:m0d5oyv -->+    - Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift+    - Cover: single-flight; attach for same hostname; .open refused when attempted; .request starts when attempted; .open pre-empts .background/.open only, .request pre-empts anything; pre-emption ends the sweep; background refused on budget/LowPower/thermal/inactive; timeout → attempted + charged; CancellationError → unattempted + charged; invalidate clears held/attempted/fingerprint but not dismissed; dismissed per side; memory warning cancels in-flight and clears held/attempted/fingerprints; resignActive cancels only .background; fingerprint mismatch → invalidate+    - Blocked-by: m0d5oyn (Add the AsterismIntelligence product, target and test target and define its shared types)+    - Stream: 1+    - Requirements: [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.11](requirements.md#5.11), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)+  - [x] 4.2. Implement RuleSuggestionLedger to pass the tests <!-- id:m0d5oyw -->+    - Sources/AsterismIntelligence/RuleSuggestionLedger.swift — pure struct, Sendable, Equatable; environment inputs (isActive, lowPower, thermal) passed as parameters, no Foundation lookups inside+    - inFlight stores (hostname, origin) only — the Task lives in the coordinator+    - Blocked-by: m0d5oyv (Write tests for every ledger transition in the design's state table and rules)+    - Stream: 1+    - Requirements: [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.11](requirements.md#5.11), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)++- [x] 5. FoundationRuleSuggestionModelClient <!-- id:m0d5oyx -->+  - Stream: 1+  - Requirements: [4.1](requirements.md#4.1), [4.3](requirements.md#4.3)+  - [x] 5.1. Write tests for the model client: availability mapping; instructions and prompt construction; greedy options; exceededContextWindowSize surfaced typed; one live call guarded on model availability <!-- id:m0d5oyy -->+    - Tests/AsterismIntelligenceTests/FoundationRuleSuggestionModelClientTests.swift+    - Expose the instructions text and prompt builder as testable statics; assert anchor-first numbering and the two few-shot examples+    - Live test: `guard SystemLanguageModel.default.isAvailable` else withKnownIssue; asserts a RuleProposal decodes for a two-example corpus+    - Blocked-by: m0d5oyn (Add the AsterismIntelligence product, target and test target and define its shared types), m0d5oyr (Corpus selection)+    - Stream: 1+    - Requirements: [4.1](requirements.md#4.1), [4.3](requirements.md#4.3)+  - [x] 5.2. Implement FoundationRuleSuggestionModelClient to pass the tests <!-- id:m0d5oyz -->+    - Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift: one LanguageModelSession per propose(), GenerationOptions(sampling: .greedy), respond(to:generating: RuleProposal.self); map SystemLanguageModel.default.availability to ModelAvailability; rethrow GenerationError.exceededContextWindowSize as-is so the suggester can halve; do not catch CancellationError+    - Return the .content out of any Task — Response<Content> is not Sendable+    - Blocked-by: m0d5oyy (Write tests for the model client: availability mapping; instructions and prompt construction; greedy options; exceededContextWindowSize surfaced typed; one live call guarded on model availability)+    - Stream: 1+    - Requirements: [4.1](requirements.md#4.1), [4.3](requirements.md#4.3)++## Core candidate read++- [x] 6. ruleSuggestionCandidates(hostnames:) <!-- id:m0d5oz0 -->+  - Stream: 2+  - Requirements: [1.6](requirements.md#1.6), [5.1](requirements.md#5.1), [5.5](requirements.md#5.5)+  - [x] 6.1. Write repository tests for ruleSuggestionCandidates(hostnames:): all vs subset; siteMode; rule versions; entryCount and latestCaptureAt; duplicate-hostname winner; hostname with no entries <!-- id:m0d5oz1 -->+    - Packages/AsterismCore/Tests/AsterismCoreTests/RuleSuggestionCandidatesTests.swift using the ComposedTeachingRepositoryTests temp-store pattern+    - Cases: nil hostnames returns every winner; subset returns only those; siteMode for untaught/taught/articles; titleRuleVersion from activePattern, urlRuleVersion from current urlRules; entryCount and latestCaptureAt; a hostname with two Site rows yields the SiteResolutionOrder winner once; a Site with zero entries yields entryCount 0 and nil latestCaptureAt+    - Stream: 2+    - Requirements: [1.6](requirements.md#1.6), [5.1](requirements.md#5.1), [5.5](requirements.md#5.5)+  - [x] 6.2. Implement the read on LibraryProviding and LibraryRepository, and add the preset to MockLibraryProvider <!-- id:m0d5oz2 -->+    - Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RuleSuggestion.swift: `ruleSuggestionCandidates(hostnames: Set<String>?)` under withLockedContext(.shared); per hostname a count FetchDescriptor<Entry> and a fetchLimit-1 descriptor sorted by firstCapturedAt desc; never traverse Site.entries+    - Add to LibraryProviding (LibraryProviding.swift) and a preset `ruleSuggestionCandidatesResult` on Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+    - Blocked-by: m0d5oz1 (Write repository tests for ruleSuggestionCandidates(hostnames:): all vs subset; siteMode; rule versions; entryCount and latestCaptureAt; duplicate-hostname winner; hostname with no entries)+    - Stream: 2+    - Requirements: [1.6](requirements.md#1.6), [5.1](requirements.md#5.1), [5.5](requirements.md#5.5)++## App: assembly, suggester, coordinator++- [x] 7. Prepare the editor seams: nonisolated presentation helpers and types; URLEditorState.setSplit; seedTitleEditor(definition:trimPrefix:trimSuffix:) extraction <!-- id:m0d5oz3 -->+  - Asterism/Asterism/Views/ComposedTeachingPresentation.swift: `nonisolated` on titleSelection, titleChips, tokenRanges, inferredTitleRule, private splits, and on TitleSegment/TitleChip/InferredTitleRule (so synthesized == is usable off-actor)+  - Asterism/Asterism/Views/ComposedURLEditorState.swift: internal `mutating func setSplit(_ selection: URLTwoFieldSelection)` that sets `split` only — `toggleSplitToken` does not clear the retained template either; `rule(in:)` treats a live split as superseding it, and the assembler starts from a fresh state (corrected after phase 3 review)+  - Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift: extract `seedTitleEditor(definition:trimPrefix:trimSuffix:)`; `seedTitleEditor(from: ComposedTitleRuleBasis)` becomes a wrapper+  - Existing ComposedTeachingViewModelTests and Core tests must stay green; `make test-quick` is the check+  - Stream: 3+  - Requirements: [1.4](requirements.md#1.4), [5.1](requirements.md#5.1)++- [x] 8. RuleSuggestionAssembler <!-- id:m0d5oz4 -->+  - Stream: 3+  - Requirements: [1.4](requirements.md#1.4), [3.1](requirements.md#3.1), [3.5](requirements.md#3.5)+  - [x] 8.1. Write tests for RuleSuggestionAssembler title and URL assembly <!-- id:m0d5oz5 -->+    - Asterism/AsterismTests/RuleSuggestionAssemblerTests.swift+    - Title: work-only → nil unless URL exists is the suggester's job, so here assert whole-title default is returned; work+chapter segments → segment form; sub-segment phrase; unauthorable (whitespace-only separator) → nil; chapter before work+    - URL: identity in one path component → work locator; identity+sequence in different components → two-slot; same component → combined template via setSplit; identity in the host → nil; refused selection → nil+    - Blocked-by: m0d5oyn (Add the AsterismIntelligence product, target and test target and define its shared types), m0d5oz3 (Prepare the editor seams: nonisolated presentation helpers and types; URLEditorState.setSplit; seedTitleEditor(definition:trimPrefix:trimSuffix:) extraction)+    - Stream: 3+    - Requirements: [1.4](requirements.md#1.4), [3.1](requirements.md#3.1), [3.5](requirements.md#3.5)+  - [x] 8.2. Implement RuleSuggestionAssembler to pass the tests <!-- id:m0d5oz6 -->+    - Asterism/Asterism/RuleSuggestion/RuleSuggestionAssembler.swift — `nonisolated enum`; title path: titleSelection → titleChips → inferredTitleRule; URL path: fresh URLEditorState, select/setSplit, rule(in:), accept only .valid+    - Blocked-by: m0d5oz5 (Write tests for RuleSuggestionAssembler title and URL assembly)+    - Stream: 3+    - Requirements: [1.4](requirements.md#1.4), [3.1](requirements.md#3.1), [3.5](requirements.md#3.5)++- [x] 9. RuleSuggester attempt pipeline <!-- id:m0d5oz7 -->+  - Stream: 3+  - Requirements: [1.6](requirements.md#1.6), [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.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2), [5.10](requirements.md#5.10)+  - [x] 9.1. Write tests for RuleSuggester.attempt(hostname:) with MockLibraryProvider and StubRuleSuggestionModelClient <!-- id:m0d5oz8 -->+    - Asterism/AsterismTests/RuleSuggesterTests.swift; MockLibraryProvider preset contracts for the basis call and each verification call, keyed by request+    - Cases: happy path both sides; pair fails → title-only with stored URL rule; URL-only with stored title rule / .wholeTitle; no chapter and no sequence → nil with zero verification calls; articles basis throw → nil; exceededContextWindowSize → halved retry; other GenerationError → nil; timeout → AttemptTimeout with modelPhase set; CancellationError propagates; default-title-only dropped; entry with titleFailure fails the set; workName nil fails+    - Blocked-by: m0d5oyr (Corpus selection), m0d5oz4 (RuleSuggestionAssembler)+    - Stream: 3+    - Requirements: [1.6](requirements.md#1.6), [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.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2), [5.10](requirements.md#5.10)+  - [x] 9.2. Implement RuleSuggester to pass the tests: basis; corpus; model with context halving; locate; short-circuit; assemble; pair-then-singles verification; timeout wrapper; modelPhase <!-- id:m0d5oz9 -->+    - Asterism/Asterism/RuleSuggestion/RuleSuggester.swift — actor; steps 1–7 of the design; timeout wrapper cancels the child and throws AttemptTimeout; returns (suggestion, modelPhase)+    - Verification uses library.projectComposedTeaching with permitsArticlesConversion false; read contract.outcome per design step 6+    - Blocked-by: m0d5oz8 (Write tests for RuleSuggester.attempt(hostname:) with MockLibraryProvider and StubRuleSuggestionModelClient)+    - Stream: 3+    - Requirements: [1.6](requirements.md#1.6), [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.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2), [5.10](requirements.md#5.10)++- [x] 10. RuleSuggestionCoordinator <!-- id:m0d5oza -->+  - Stream: 3+  - Requirements: [4.1](requirements.md#4.1), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.11](requirements.md#5.11), [6.6](requirements.md#6.6)+  - [x] 10.1. Write tests for RuleSuggestionCoordinator with an injectable environment: active state; Low Power; thermal; clock <!-- id:m0d5ozb -->+    - Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift; inject a `SuggestionEnvironment` protocol (isActive, isLowPowerModeEnabled, thermalState, clock) and a stub RuleSuggester seam+    - Cases: activationSweep respects depth, order, eligibility, single instance; reconcile invalidates on fingerprint mismatch and passes only tracked hostnames; suggestion(for:.open) returns held without a call, refuses when attempted, attaches when in flight; .request starts when attempted; resignActive cancels background only; memoryWarning; isModelAvailable re-read on sweep; budget charged from modelPhase; caller cancellation does not cancel the attempt+    - Blocked-by: m0d5oyu (RuleSuggestionLedger), m0d5oz0 (ruleSuggestionCandidates(hostnames:)), m0d5oz7 (RuleSuggester attempt pipeline)+    - Stream: 3+    - Requirements: [4.1](requirements.md#4.1), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.11](requirements.md#5.11), [6.6](requirements.md#6.6)+  - [x] 10.2. Implement RuleSuggestionCoordinator to pass the tests <!-- id:m0d5ozc -->+    - Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift — @MainActor @Observable; owns RuleSuggestionLedger, RuleSuggester, the in-flight Task, and pending continuations for attached callers; production SuggestionEnvironment reads ProcessInfo.processInfo.isLowPowerModeEnabled / .thermalState and the app's active state+    - Pre-emption awaits the cancelled task before starting the next+    - Blocked-by: m0d5ozb (Write tests for RuleSuggestionCoordinator with an injectable environment: active state; Low Power; thermal; clock)+    - Stream: 3+    - Requirements: [4.1](requirements.md#4.1), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.11](requirements.md#5.11), [6.6](requirements.md#6.6)++## App: editor integration++- [x] 11. ComposedTeachingViewModel suggestion behaviour <!-- id:m0d5ozd -->+  - Stream: 3+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)+  - [x] 11.1. Write ComposedTeachingViewModelTests for seeding; markers; edit detection; clear; effectiveTitleRule; cannot-depict; late arrival; request; dismissal; preview regeneration; save path <!-- id:m0d5oze -->+    - Extend Asterism/AsterismTests/ComposedTeachingViewModelTests.swift; makeSUT gains a coordinator (or a protocol seam) with preset held/attempted/dismissed state+    - Cases per design's view-model table: seeding untaught sides only; retained wins on taught; markers per side; commitTitleEdit → flag off + dismiss(.title); updateURLRule with a differing outcome → flag off + dismiss(.url); re-seed via equal outcome does not dismiss; clearSuggestedSide restores snapshot (untaught / retained); snapshot taken once; effectiveTitleRule returns suggestion after apply on taught side; cannot-depict → snapshot restored and no storedTitleRuleNotice; late arrival → suggestionReady not applied; within-window applies untouched sides only; requestSuggestion busy → applied / suggestionUnavailableNotice; request applies to dismissed side and clears dismissal; preview regenerated after apply; commit of differing rule dismisses; identical commit writes no version (request unchanged)+    - Blocked-by: m0d5oza (RuleSuggestionCoordinator)+    - Stream: 3+    - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)+  - [x] 11.2. Implement the view model additions to pass the tests <!-- id:m0d5ozf -->+    - Asterism/Asterism/ViewModels/ComposedTeachingViewModel.swift: members from the design table (suggestions, titleSuggestionApplied, urlSuggestionApplied, appearedAt, urlTouched, applySuggestion, clearSuggestedSide, requestSuggestion, seedSuggestionIfAvailable, suggestionBusy, suggestionReady, suggestionUnavailableNotice); effectiveTitleRule change; hooks in load(), commitTitleEdit(), updateURLRule(_:), commit success path+    - URL cannot-depict check: headless URLEditorState.seed(from:in:) + rule(in:) on the opened entry's components before publishing urlRuleDefinition+    - Blocked-by: m0d5oze (Write ComposedTeachingViewModelTests for seeding; markers; edit detection; clear; effectiveTitleRule; cannot-depict; late arrival; request; dismissal; preview regeneration; save path)+    - Stream: 3+    - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6)++- [x] 12. Add the marker; clear action; Suggest action; no-suggestion message to ComposedTeachingView <!-- id:m0d5ozg -->+  - Asterism/Asterism/Views/ComposedTeachingView.swift: marker rows styled like storedTitleRuleNotice (:147-155) with `sparkles`, identifiers composed-title-suggested / composed-url-suggested, VoiceOver labels naming the side; clear buttons composed-*-suggested-clear; Suggest action in the confirm row beside urlAnchoringPendingRow, hidden when model unavailable or siteMode == .articles, busy uses the pending-row ProgressView pattern, ready shows a sparkles badge (composed-suggest, -busy, -ready); no-suggestion message composed-suggest-unavailable in the storedTitleRuleNotice slot+  - Containers holding buttons: .accessibilityIdentifier plus .accessibilityElement(children: .contain)+  - Blocked-by: m0d5ozd (ComposedTeachingViewModel suggestion behaviour)+  - Stream: 3+  - Requirements: [2.1](requirements.md#2.1), [2.3](requirements.md#2.3), [6.1](requirements.md#6.1), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4)++- [x] 13. Wire the coordinator into AppLibraryModel and ContentView; add the UI-test stub client launch environment <!-- id:m0d5ozh -->+  - AppLibraryModel.swift: own the coordinator (FoundationRuleSuggestionModelClient in production); handleActivation() → `Task { await suggestions.activationSweep() }` after refreshDiagnosesAndSnapshots(); refreshDiagnosesAndSnapshots() → `await suggestions.reconcile()` after refreshAll(); composedTeachingModel(for:) and (forHostname:) inject suggestions+  - ContentView.swift: onReceive willResignActiveNotification → resignActive(); didReceiveMemoryWarningNotification → memoryWarning()+  - UITestLaunchSupport: `ASTERISM_UI_TEST_SUGGESTION=canned|unavailable` selects StubRuleSuggestionModelClient with a canned proposal for the seeded-composed scenario or an unavailable availability+  - Blocked-by: m0d5oza (RuleSuggestionCoordinator), m0d5ozd (ComposedTeachingViewModel suggestion behaviour)+  - Stream: 3+  - Requirements: [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [5.1](requirements.md#5.1), [5.3](requirements.md#5.3), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6)++- [x] 14. Write ComposedSurfaceUITests scenarios for a canned suggestion and for model-unavailable <!-- id:m0d5ozi -->+  - Asterism/AsterismUITests/ComposedSurfaceUITests.swift: canned → composed-title-suggested exists on open, tapping composed-title-chip-0 removes it, composed-suggest exists; unavailable → none of composed-title-suggested / composed-url-suggested / composed-suggest exist+  - Runs under `make test-ui` on the simulator only+  - Blocked-by: m0d5ozg (Add the marker; clear action; Suggest action; no-suggestion message to ComposedTeachingView), m0d5ozh (Wire the coordinator into AppLibraryModel and ContentView; add the UI-test stub client launch environment)+  - Stream: 3+  - Requirements: [1.1](requirements.md#1.1), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [4.1](requirements.md#4.1), [6.1](requirements.md#6.1)

Things to double-check

Learnings worth reusing
  • With constrained decoding, ask a small model for verbatim text and locate it in code — never for offsets. Accept only a unique occurrence, so a bad answer degrades to 'no answer' rather than a wrong span.
    ProposalLocator.place returns unique/absent/ambiguous; the URL locator searches every path component and query value and drops a field found in two of them.
  • OSLog's string interpolation is an escaping autoclosure, so a non-escaping @autoclosure parameter cannot be passed straight into it — bind to a local first. And `privacy:` accepts only a literal OSLogPrivacy member, so a DEBUG/release split must wrap the whole call.
    RuleSuggestionLog.note binds `reason` and `content` to locals, then duplicates the logger.log call under #if DEBUG.
  • SwiftUI collapses a Button's label into one accessibility element, and a container one level under a DisclosureGroup inherits the group's identifier. Queryable identifiers must sit on leaves or overlays.
    composed-suggest-busy is an .overlay, not part of the button's Label; composed-url-suggested sits on a combined Label leaf, because a row-level identifier inside the disclosure came out as composed-url-disclosure.
  • Under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, every static, every initializer and even a synthesized == is main-actor bound. Pure helpers used off the main actor need explicit `nonisolated` — and an actor's synchronous initializer cannot take it at all.
    titleSelection/titleChips/inferredTitleRule and TitleSegment/TitleChip/InferredTitleRule are annotated nonisolated; RuleSuggester's init stays main-actor with a comment recording that the compiler rejects the alternative.
  • A test double that records calls needs a lock as soon as a suite can overlap two calls. The symptom is a different test failing each run while the reported test passes.
    MockLibraryProvider's projectComposedRequests moved behind an NSLock after the suggester's request log broke the generation-gating tests; StubRuleSuggestionModelClient.Recorder uses a Mutex so value-type copies share one call log.
  • A withThrowingTaskGroup timeout should cancelAll and let scope exit await the loser, so the timeout means 'work has stopped', not 'work has been abandoned'. It is still only a stop request if the work ignores cancellation.
    RuleSuggester.attempt races run(...) against Task.sleep(for: timeout) and throws AttemptTimeout carrying the elapsed modelPhase.
Open questions for the author
  • Req 4.4 (the extension never links FoundationModels) has no automated guard — it rests on two frameworks-phase entries and a manual grep. Should make verify-identity assert it?
  • All five RuleSuggestionBounds constants are provisional and the device latency spike is outstanding; the one host measurement (~15 s cold) already exceeds the 10 s attemptTimeout. Should the spike gate the merge?
  • The pre-emption retry in RuleSuggestionCoordinator.suggestion(for:origin:candidate:) is a while-true with no iteration cap; nothing states why it cannot ping-pong under sustained open/request contention.
  • The Q44 short-circuit means a hostname with no chapter in its titles and no sequence in its URLs can never be suggested for, even where a title rule alone would be correct. Intended coverage, or an accepted blind spot?
  • suggestionUnavailableNotice persists until the next request rather than clearing on the reader's next edit — recorded as a known gap with no rationale for leaving it.
Latency spike (prerequisite, pending)

Personal build with RuleSuggestion logging installed 2026-08-18 with approval. Read Console for subsystem:me.nore.ig.Asterism category:RuleSuggestion: first settle after launch is cold, later ones warm. If cold routinely exceeds 10 s, widen attemptTimeout or exclude session warm-up from it before finalising the bounds.

Req 4.4 has no automated guard

The extension never linking AsterismIntelligence/FoundationModels is enforced only by two frameworks-phase entries in project.pbxproj and a manual grep. A one-line check in make verify-identity would close it.