asterism branch worktree-character-extraction-pronouns commits 2 + review fixes files 11 touched lines +787 / -31

Pre-push review: character-extraction pronoun fold + capitalisation backstop

Two commits on top of the merged character-extraction feature: the prompt folds pronoun facts into named characters, and grounding gains two deterministic drops — pronoun names and names the note never capitalises. Review fixes unified the name rules so the slash-split path can no longer bypass the pronoun check.

At a glance

  • Prompt: a pronoun is never a character — report its fact under the named character it refers to; places and activities named as non-characters with examples (Q113/Q114).
  • Grounding: pronounName drop (fixed English list against the name key) and nameNeverCapitalised drop (any cased non-lowercase letter in any occurrence; uncased scripts exempt) — Q115, Decision 6.
  • Review fix: one dropReason(for:in:) list shared by the whole name and every slash component; one presence(of:in:) scan answers in-source and ever-capitalised together.
  • Evidence: host-side harness over the 2026-08-08 export — 84/84 real-name occurrences capitalised; model self-classification and NLTagger both mislabel real characters as places, so neither shipped.
  • Left open: capitalised places ("Tokyo", "Brockton Bay") still reach the review list; a conjunction rule (model-place AND tagger-place) is recorded as a follow-up.

Verdict

Ready to push

The one real finding (split components skipped the pronoun rule) is fixed and tested; the three tidy-ups the agents converged on (single scan, isCased, titlecase-safe capital test) landed with it. make test-core exits 0 after the fixes with no known issues; the decision log records the measured rejections. Capitalised place names remain a review-list skip by design, not by omission.

Review findings

8 raised · 6 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The phone proposed "He", "his", "sex" and "hotel" as characters. The model's instructions now say a pronoun is never a character (report its fact under the named character instead) and list places and activities as non-characters. The host-side grounding step — the fact-checker that discards what the note doesn't support — gained two rules: drop a candidate whose name is a pronoun, and drop one the note never writes with a capital letter.

Why it matters

The phone's model ignored the wording change, so only a rule the app applies itself is reliable. The capital-letter rule works because this reader writes every real name with a capital (84 of 84 in the export) and junk in lowercase.

Key concepts

  • Grounding — deterministic checks on model output.
  • Drop reason — the rule that removed a candidate, visible in Console.
  • Review flow — whatever slips through is a one-tap, remembered skip.

Changes

  • FoundationCharacterExtractionModelClient.instructions: pronoun attribution rule; places/activities as non-characters.
  • CharacterGrounding: two new GroundingDrop.Reason cases; name rules consolidated into dropReason(for:in:) (empty → pronoun → length → presence) shared by the whole-name path and every slash component; presence(of:in:) does one case-insensitive scan for both in-source and ever-capitalised.
  • Tests for every new behaviour including split cancellation on pronoun or lowercase components.
  • Spec: Q113/Q115, Q114 promoted to Decision 6, design and implementation text, harness and findings under prototype/.

Approach

The fold is the model's job (coreference needs its reading); the drop is the host's (mechanical, holds across model versions). The pronoun list is matched on the name key, so "The It" keys to it and drops — consistent with keys being the identity aliases route on. "Capital" means any cased letter that is not lowercase, in any occurrence, so "prince Kyllian", "dean Ryu" and titlecase digraphs pass.

Trade-offs

Measured and rejected: schema self-classification (mislabels Armsmaster, Blob; extra guardrail refusal), NLTagger (Grover, Bruce, Batman tagged as places), gazetteer (Phoenix, Savannah). A drop is unrecoverable short of hand-creation, so a rule that sometimes kills a real character is worse than none. Cost: capitalised places and sentence-initial common nouns still pass.

Deep dive

presence(of:in:) resumes from each match's upperBound; needle NFC-composed once, haystack already NFC. Termination: needle non-empty on every path (emptiness is the first dropReason guard) and Foundation returns nil for an empty search. Linear in haystack + occurrences, bounded by 24 candidates and the model's context window, on the CharacterExtractor actor behind an 8–24 s model call. Uncased needles short-circuit to .present. The split hole — component predicate lacked the pronoun check, so "He/Hanna" could ground as "He" with alias "Hanna" — is closed by sharing dropReason.

Architecture impact

Two enum cases; the only consumer logs rawValue, no exhaustive switch. The harness is a standalone SwiftPM package under specs/, referenced by nothing in the Makefile or xcodeproj; .build/ ignored at any depth.

Potential issues

  • A reader who writes names in lowercase loses them all — one guard to relax; this reader does not.
  • The Mac's model never reproduced "sex"/"hotel" on the same notes the phone did: host-measured prompt tuning is weak evidence for the phone.
  • Capitalised places remain. The conjunction rule (model-place AND tagger-place) had no false positive in 27 notes but needs the schema change; deferred in Decision 6.

Important changes — detailed

CharacterGrounding: one dropReason list for names and split components

Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift

Why it matters. Closes the path where "He/Hanna" grounded as a character named He: the split predicate had its own copy of the checks and never included the pronoun rule. Now both paths cannot drift.

What to look at. CharacterGrounding.swift — dropReason(for:in:), split()

Takeaway. When two code paths must enforce the same rule set, return the first failing reason from one function and let both paths call it; a copied predicate is where a new rule gets forgotten.
Rationale. Raised by the quality and spec agents independently; the unification was also what the reuse and efficiency agents asked for, so one change answered four findings.

CharacterGrounding: presence(of:in:) — one scan for in-source and ever-capitalised

Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift

Why it matters. The Q115 rule: a candidate never written with a capital anywhere in the note is a common noun. Any cased non-lowercase letter in any occurrence passes; uncased scripts are exempt.

What to look at. CharacterGrounding.swift — Presence, presence(of:in:)

Takeaway. The source's own spelling is a deterministic proper-name signal when the corpus is one person's writing; validate the assumption with a census before relying on it (84/84 here).
Rationale. Decision 6: every alternative (schema kind, NLTagger, gazetteer) mislabelled real characters; a drop is unrecoverable short of hand-creation.

CharacterGrounding: pronoun drop on the name key

Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift

Why it matters. "he" grounds in nearly every note, so Req 1.6 alone can never catch it. Matching on the normalised key means "The It" keys to it and drops — the key is what aliases and combines route on.

What to look at. CharacterGrounding.swift — pronouns, dropReason

Takeaway. Put stoplist checks on the same normalised identity the rest of the system routes on, or a stripped form slips through and lands as a routable key.
Rationale. Q113; a character literally named "It" is lost and recovered by hand-creation (Q39).

Model client: pronoun attribution and non-character examples in the instructions

Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift

Why it matters. Folding a pronoun's fact into the right character needs coreference, which only the model can do; the host can only drop. The attribution rule therefore lives in the prompt.

What to look at. FoundationCharacterExtractionModelClient.swift — instructions

Takeaway. Split responsibilities by who has the information: the model attributes, the host enforces. Do not expect the prompt to enforce anything on its own.
Rationale. Q113/Q114. The phone's model ignored the place/activity wording; the Mac's never reproduced the junk, so host-side prompt tests are weak evidence (findings file).

Harness and findings under specs/character-extraction/prototype/

specs/character-extraction/prototype/junk-harness-findings.md

Why it matters. The evidence behind Decision 6: 27 notes, 54 model calls, capitalisation census, NLTagger census. Lets the deferred conjunction rule be re-measured later.

What to look at. junk-harness-findings.md; junk-harness/Sources/main.swift; junk-harness/Census/main.swift

Takeaway. When a fix is chosen over plausible alternatives, keep the harness that rejected them next to the spec so the next person can re-run it instead of re-arguing it.
Rationale. Mirrors the original prototype's placement (Q28, Decision 3); standalone package by design, untouched by any build target.

Key decisions

Fold in the prompt, drop in grounding.

Coreference needs the model's reading; the host has nothing to fold a pronoun's facts into, so a dropped pronoun candidate loses its facts and the prompt is where attribution happens (Q113).

Capitalisation over classification.

Decision 6: schema kind mislabelled Armsmaster and Blob, NLTagger called Grover/Bruce/Batman places, a gazetteer hits Phoenix/Savannah. The note's spelling lost 0 of 84 real names.

Any-letter, any-occurrence capital test.

The harness's first-letter variant would have dropped "prince Kyllian" and "dean Ryu", which the notes write lowercase-first. isCased && !isLowercase also admits titlecase digraphs.

Pronoun check on the normalised key, not the raw spelling.

"The It" keys to it; keeping it would leave an it key that alias routing treats as identity with nothing guarding it. The surprise is the smaller cost.

Capitalised place names stay in the review flow.

No deterministic rule found that removes them without losing real characters. The conjunction rule (model-place AND tagger-place) is deferred with the harness kept to re-measure it.

Q114 promoted rather than deleted.

The prompt wording still ships; only its "prompt only, no host rule" stance was wrong. Decision 6's status says so explicitly after the spec agent flagged "supersedes Q114" as overstated.

Review findings

SeverityAreaFindingResolution
majorCharacterGrounding.splitSplit components were checked for emptiness, length, presence and capitalisation but not pronouns, and split runs before the pronoun guard — "He/Hanna" could ground as a character named He with alias Hanna.Name rules consolidated into dropReason(for:in:); split's predicate now calls it. Test pronounComponentCancelsTheSplit added.
minorCharacterGrounding scanscontains and isEverCapitalised scanned the haystack twice per name with the needle composed twice.presence(of:in:) returns absent / lowercaseOnly / present from one scan; contains stays for quotes.
nitCharacterGrounding cased-letter testneedle.lowercased() != needle.uppercased() allocates two strings; Character.isUppercase is false for titlecase digraphs.needle.contains(where: \.isCased); capital test is isCased && !isLowercase.
minorspecs/character-extraction/implementation.mdGrounding description listed only verbatim-quote, presence and length checks.Sentence now names the pronoun and never-capitalised drops with Q113/Q115/Decision 6.
nitdecision_log.md Decision 6 status"supersedes Q114" overstated: Q114's prompt wording still ships.Status now reads "supersedes Q114's prompt-only stance (the prompt wording itself still ships)".
nitCharacterGroundingTestsPronoun-before-nameNotInSource ordering untested; Hermione's role in pronounRuleIsWholeNameOnly unexplained.absentPronounIsStillAPronoun added; comment on the Hermione row.
nitrequirements.mdNo acceptance criterion covers the two new drops; they are specified by Q113/Q115/Decision 6 only.Skipped — Req 1.6 states a necessary condition, not a sufficient one; the decision log is the house location for amendments (Q111/Q112 precedent).
nitjunk-harness duplicationThe harness copies the prompt, key normalisation and pronoun list instead of depending on AsterismIntelligence.Skipped — standalone by design like the original prototype; its first-letter variant is deliberately different from the shipped rule for comparison.

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift Modified +91 / -17
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swiftindex ab38185..26b2c1e 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift@@ -9,6 +9,8 @@ public struct GroundingDrop: Sendable, Equatable {         case emptyName         case nameTooLong         case nameNotInSource+        case pronounName+        case nameNeverCapitalised         case emptyStatement         case statementTooLong         case emptyQuote@@ -43,8 +45,10 @@ public struct GroundingOutcome: Sendable, Equatable { /// identically on any device — and so the prototype harness and the app can run /// the same rules. ///-/// Three questions, in order: does the name appear in the source, does the-/// quote appear in it verbatim, and is everything inside the bounds. Comparison+/// Five questions, in order: is the name a pronoun, does the name appear in+/// the source, is it ever written with a capital there, does the quote appear+/// in it verbatim, and is everything inside the bounds — the name rules in one+/// list (`dropReason`) that a slash component is held to as well. Comparison /// is case-insensitive over NFC — the reader's note and the model's echo of it /// differ in case and composition far more often than they differ in letters — /// and never letter-insensitive: folding diacritics away would make Renée and@@ -84,37 +88,107 @@ public enum CharacterGrounding {     private static func groundName(_ raw: String, in haystack: String,                                    drops: inout [GroundingDrop]) -> GroundedName? {         let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)-        guard !name.isEmpty else {-            drops.append(GroundingDrop(name: raw, reason: .emptyName))-            return nil-        }          // The split is tried first: "Hanna/Action Girl" is how the notes write         // one character's two names, and it is almost never in the note as one         // string. A component that does not ground cancels the split, and the-        // compound then stands or falls on the ordinary 1.6 check.+        // compound then stands or falls on the ordinary checks.         if let split = split(name, in: haystack) { return split } -        guard name.count <= CharacterExtractionBounds.maximumNameLength else {-            drops.append(GroundingDrop(name: name, reason: .nameTooLong))-            return nil-        }-        guard contains(name, in: haystack) else {-            drops.append(GroundingDrop(name: name, reason: .nameNotInSource))+        if let reason = dropReason(for: name, in: haystack) {+            drops.append(GroundingDrop(name: name.isEmpty ? raw : name, reason: reason))             return nil         }         return GroundedName(name: name, key: CharacterNameKey.normalize(name), aliases: [])     } +    /// The first rule a name fails, or nil when it grounds. One list, applied+    /// to a whole name and to every slash component alike, so the two paths+    /// cannot drift — a split must not let "He/Hanna" through as a character+    /// named He.+    ///+    /// The pronoun rule (Q113) comes before the source check because a pronoun+    /// passes it in almost any note — "he" is in nearly all of them — so it is+    /// the one junk shape grounding has to refuse by name. Its facts go with+    /// it: the prompt asks the model to report them under the character the+    /// pronoun stands for, and without the model's reading there is nothing+    /// host-side to fold them into.+    private static func dropReason(for name: String, in haystack: String) -> GroundingDrop.Reason? {+        guard !name.isEmpty else { return .emptyName }+        guard !pronouns.contains(CharacterNameKey.normalize(name)) else { return .pronounName }+        guard name.count <= CharacterExtractionBounds.maximumNameLength else { return .nameTooLong }+        switch presence(of: name, in: haystack) {+        case .absent: return .nameNotInSource+        case .lowercaseOnly: return .nameNeverCapitalised+        case .present: return nil+        }+    }++    private enum Presence {+        case absent+        /// In the source, but never with a capital letter.+        case lowercaseOnly+        case present+    }++    /// One case-insensitive scan answering Req 1.6 and Q115 together: is the+    /// name in the source, and is it ever written there with a capital letter?+    ///+    /// The junk the prompt cannot talk the model out of — "sex", "hotel",+    /// "general", "gun girl", "the weird lady" — is written in lowercase in the+    /// note, and the reader's real names never are: 84 of 84 name occurrences+    /// in the export the prototype ran over carry a capital. So the note's own+    /// spelling is the one deterministic signal that separates a proper name+    /// from a common noun, and it is checked *any letter, any occurrence*+    /// rather than first-letter-of-every-word so that "prince Kyllian",+    /// "van Helsing" and "al-Hakim" all pass on their capitalised part.+    /// "Capital" means a cased letter that is not lowercase, so titlecase+    /// digraphs count too.+    ///+    /// What it does not catch, by design: capitalised places ("Tokyo",+    /// "Brockton Bay" — no safe host-side rule exists; see Decision 6) and a+    /// common noun that opens a sentence ("Sex was the whole chapter"). Both+    /// stay the review flow's job. A name with no cased letters at all+    /// (Hangul, kana, CJK) is present the moment it is found: there is nothing+    /// to check.+    private static func presence(of name: String, in haystack: String) -> Presence {+        let needle = name.precomposedStringWithCanonicalMapping+        let uncased = !needle.contains(where: \.isCased)+        var found = Presence.absent+        var searchFrom = haystack.startIndex+        while let range = haystack.range(of: needle, options: [.caseInsensitive],+                                         range: searchFrom..<haystack.endIndex) {+            if uncased || haystack[range].contains(where: { $0.isCased && !$0.isLowercase }) {+                return .present+            }+            found = .lowercaseOnly+            searchFrom = range.upperBound+        }+        return found+    }++    /// English personal pronouns, compared against the name *key* so case is+    /// already folded. Exactly these: the shipped sweep proposed "He", "his"+    /// and "they" as characters, and "everyone"-class words stay the prompt's+    /// job (Q55) — a stoplist of common nouns would be guessing at the corpus.+    /// A story whose character is literally named "It" loses that candidate+    /// to this rule; hand-creation (Q39) is the way back.+    static let pronouns: Set<String> = [+        "i", "me", "my", "mine", "myself",+        "you", "your", "yours", "yourself", "yourselves",+        "he", "him", "his", "himself",+        "she", "her", "hers", "herself",+        "it", "its", "itself",+        "we", "us", "our", "ours", "ourselves",+        "they", "them", "their", "theirs", "themselves",+    ]+     private static func split(_ name: String, in haystack: String) -> GroundedName? {         guard name.contains("/") else { return nil }         let components = name.split(separator: "/", omittingEmptySubsequences: false)             .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }         guard components.count >= 2 else { return nil }-        guard components.allSatisfy({-            !$0.isEmpty && $0.count <= CharacterExtractionBounds.maximumNameLength-                && contains($0, in: haystack)-        }) else { return nil }+        guard components.allSatisfy({ dropReason(for: $0, in: haystack) == nil }) else { return nil }          let head = components[0]         let key = CharacterNameKey.normalize(head)
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift Modified +19 / -9
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swiftindex 74757d3..34e69b9 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift@@ -44,14 +44,18 @@ public struct FoundationCharacterExtractionModelClient: CharacterExtractionModel      // MARK: - Prompting -    /// The prototype's instructions, tightened by its findings (Q55).+    /// The prototype's instructions, tightened by its findings (Q55) and by+    /// the first real runs (Q113, Q114).     ///-    /// The junk tail it produced — "everyone", "the general", "redevelopment-    /// law" — was ~25% of candidates and is prompt-shaped, so the task is-    /// stated as **named story characters**, and the model is told what is not-    /// one. What the tightening misses, the review flow catches; what it must-    /// never do is invent, which is why the verbatim-quote rule is stated-    /// twice and grounding checks it anyway.+    /// The junk tail the prototype produced — "everyone", "the general",+    /// "redevelopment law" — was ~25% of candidates and is prompt-shaped, so+    /// the task is stated as **named story characters**, and the model is told+    /// what is not one. The shipped sweep then added two more shapes: pronouns+    /// reported as characters ("He", "his", "they"), whose facts belong to the+    /// named character they stand for, and places or activities ("hotel",+    /// "sex") reported as if they were people. What the tightening misses, the+    /// review flow catches; what it must never do is invent, which is why the+    /// verbatim-quote rule is stated twice and grounding checks it anyway.     static let instructions = """     You extract named story characters from a reader's private note about one \     chapter of a serial story. The note is informal and may be short.@@ -62,8 +66,14 @@ public struct FoundationCharacterExtractionModelClient: CharacterExtractionModel     A character is a person or being in the story who is referred to by a name. \     These are not characters: the reader, the author, groups and crowds \     ("everyone", "the crew"), unnamed roles ("the general", "the innkeeper"), \-    places, objects, organisations, and abstractions. If the note names no \-    characters, return an empty list.+    places ("the hotel", "New York"), objects, activities and events ("sex", \+    "the fight", "dinner"), organisations, and abstractions. If the note names \+    no characters, return an empty list.++    A pronoun is never a character. When the note says "he", "she", "they", \+    "his", "her", "their" or "it", work out which named character the word \+    refers to and report the fact under that character's name. If you cannot \+    tell which named character a pronoun refers to, leave that fact out.      Spell each name exactly as the note spells it, character for character. 
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift Modified +160 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swiftindex 7e1d14c..1ddc899 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift@@ -127,6 +127,166 @@ struct CharacterGroundingTests {         }     } +    // MARK: - Pronouns (Q113)++    /// A pronoun is in nearly every note, so the 1.6 check alone would keep+    /// "He" as a character. The drop takes its facts with it: the prompt is+    /// where they get folded into the named character, not grounding.+    @Test("A candidate named with a pronoun is dropped whatever its case, facts included")+    func pronounCandidatesAreDropped() {+        let text = "Hanna saved Jack. He had cracked ribs and his hands shook; they went home."+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "Hanna", facts: [+                ExtractedFact(statement: "saves Jack", quote: "Hanna saved Jack"),+            ]),+            ExtractedCharacter(name: "He", facts: [+                ExtractedFact(statement: "has cracked ribs", quote: "He had cracked ribs"),+            ]),+            ExtractedCharacter(name: "his", facts: [+                ExtractedFact(statement: "hands shook", quote: "his hands shook"),+            ]),+            ExtractedCharacter(name: "THEY", facts: [+                ExtractedFact(statement: "go home", quote: "they went home"),+            ]),+            ExtractedCharacter(name: "Jack", facts: []),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.map(\.name) == ["Hanna", "Jack"])+        #expect(outcome.candidates.flatMap(\.facts).map(\.quote) == ["Hanna saved Jack"])+        #expect(outcome.drops.map(\.reason) == [.pronounName, .pronounName, .pronounName])+        #expect(outcome.drops.map(\.name) == ["He", "his", "THEY"])+    }++    @Test("A pronoun absent from the note still drops as a pronoun: the log names the rule that matters")+    func absentPronounIsStillAPronoun() {+        let output = ExtractionResult(characters: [ExtractedCharacter(name: "They")])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: "Hanna waits."))++        #expect(outcome.drops.map(\.reason) == [.pronounName])+    }++    @Test("Every pronoun in the list is dropped, and only by that rule")+    func everyListedPronounIsDropped() {+        let text = CharacterGrounding.pronouns.sorted().joined(separator: " ")+        let output = ExtractionResult(characters:+            CharacterGrounding.pronouns.sorted().map { ExtractedCharacter(name: $0.capitalized) })++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.isEmpty)+        #expect(outcome.drops.count == CharacterGrounding.pronouns.count)+        #expect(outcome.drops.allSatisfy { $0.reason == .pronounName })+    }++    // MARK: - Capitalisation (Q115)++    /// The note's own spelling is the one deterministic signal for "common+    /// noun, not a name": the junk the prompt cannot suppress is lowercase+    /// in the note, and the reader's real names never are.+    @Test("A name the note never capitalises is dropped; one capitalised anywhere survives")+    func lowercaseOnlyNamesAreDropped() {+        let text = """+        Hanna went to the hotel. Then the sex scene, then prince Kyllian arrived \+        with van Helsing; jo lupo laughed. Later Jo Lupo left with al-Hakim.+        """+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "Hanna"),+            ExtractedCharacter(name: "hotel", facts: [+                ExtractedFact(statement: "is visited", quote: "went to the hotel"),+            ]),+            ExtractedCharacter(name: "sex"),+            // The capital is on the second word — still a name.+            ExtractedCharacter(name: "prince Kyllian"),+            ExtractedCharacter(name: "van Helsing"),+            // Lowercase in one place and capitalised in another: a name.+            ExtractedCharacter(name: "jo lupo"),+            // Hyphenated, capital after the hyphen.+            ExtractedCharacter(name: "al-Hakim"),+            // Absent entirely: the 1.6 check speaks first, not this one.+            ExtractedCharacter(name: "general"),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.map(\.name)+            == ["Hanna", "prince Kyllian", "van Helsing", "jo lupo", "al-Hakim"])+        #expect(outcome.drops.map(\.reason)+            == [.nameNeverCapitalised, .nameNeverCapitalised, .nameNotInSource])+        #expect(outcome.drops.map(\.name) == ["hotel", "sex", "general"])+    }++    @Test("A common noun that opens a sentence passes: the review flow owns that case")+    func sentenceInitialCapitalPasses() {+        let output = ExtractionResult(characters: [ExtractedCharacter(name: "sex")])++        let outcome = CharacterGrounding.ground(+            output, from: Self.source(text: "Sex was the whole chapter, again."))++        #expect(outcome.candidates.map(\.name) == ["sex"])+    }++    @Test("A name with no cased letters is not held to the rule")+    func uncasedScriptsPass() {+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "정교"),+            ExtractedCharacter(name: "太郎"),+        ])++        let outcome = CharacterGrounding.ground(+            output, from: Self.source(text: "정교가 太郎를 만났다."))++        #expect(outcome.candidates.map(\.name) == ["정교", "太郎"])+        #expect(outcome.drops.isEmpty)+    }++    @Test("A split component the note never capitalises cancels the split")+    func lowercaseComponentCancelsTheSplit() {+        let output = ExtractionResult(characters: [ExtractedCharacter(name: "Hanna/action girl")])++        let outcome = CharacterGrounding.ground(+            output, from: Self.source(text: "Hanna is the action girl of the piece."))++        // No split, and the compound itself is not in the note.+        #expect(outcome.candidates.isEmpty)+        #expect(outcome.drops.map(\.reason) == [.nameNotInSource])+    }++    @Test("A pronoun component cancels the split, so no alias or name can be a pronoun")+    func pronounComponentCancelsTheSplit() {+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "He/Hanna"),+            ExtractedCharacter(name: "Hanna/her"),+        ])++        let outcome = CharacterGrounding.ground(+            output, from: Self.source(text: "He and Hanna; her again."))++        // No split, and neither compound is in the note as one string.+        #expect(outcome.candidates.isEmpty)+        #expect(outcome.drops.map(\.reason) == [.nameNotInSource, .nameNotInSource])+    }++    @Test("The article strip does not turn a real name into a pronoun, and a pronoun inside a name is fine")+    func pronounRuleIsWholeNameOnly() {+        let text = "The It was there, and Hermione too. The Them arrived."+        let output = ExtractionResult(characters: [+            // Keys to "it" after Q64's article strip — still a pronoun key,+            // still dropped: the rule sees keys, not spellings.+            ExtractedCharacter(name: "The It"),+            // "Her" is a prefix of Hermione: the rule matches whole keys only.+            ExtractedCharacter(name: "Hermione"),+            ExtractedCharacter(name: "The Them"),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.map(\.name) == ["Hermione"])+        #expect(outcome.drops.map(\.reason) == [.pronounName, .pronounName])+    }+     @Test("The output caps hold whatever the model returns", arguments: 1 ... 20)     func capsHold(seed: Int) {         // A note that grounds everything, so only the caps can bound the output.
Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift Modified +14 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swiftindex beed98f..a93d59d 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift@@ -52,6 +52,20 @@ struct FoundationCharacterExtractionModelClientTests {         #expect(instructions.contains("reported with no facts"))     } +    @Test("The instructions fold pronouns into the named character and exclude places and activities")+    func instructionsNamePronounsPlacesAndActivities() {+        let instructions = FoundationCharacterExtractionModelClient.instructions++        // Q113: the model does the folding; grounding only drops what slips+        // through, so the attribution rule has to be stated here.+        #expect(instructions.contains("A pronoun is never a character"))+        #expect(instructions.contains("report the fact under that character's name"))+        // Q114: the shapes the first real runs produced are named as examples.+        #expect(instructions.contains("the hotel"))+        #expect(instructions.contains("activities and events"))+        #expect(instructions.contains("\"sex\""))+    }+     // MARK: - The request carries one source and the title, nothing else (Req 1.4)      @Test("The prompt carries the display title and the source text, and nothing else")
specs/character-extraction/decision_log.md Modified +81 / -0
diff --git a/specs/character-extraction/decision_log.md b/specs/character-extraction/decision_log.mdindex 83642ee..1c79aac 100644--- a/specs/character-extraction/decision_log.md+++ b/specs/character-extraction/decision_log.md@@ -116,6 +116,9 @@ | Q110 | 2026-08-21 | A refusal-driven review refresh reconciles held proposals (and applies any re-route target) before re-reading; the sweep checks its stop signal per source, not per work | Without these, Q66's bundle re-presentation and Req 2.7's freshness were decorative — the sheet re-presented byte-identical rows forever — and resignActive could start a fresh 30 s attempt while backgrounding | | Q111 | 2026-08-21 | Character conflicts surface as typed refusals at the acting surface (edit-step and review-sheet disclosures), not through AppLibraryModel.recordConflict(.character) | Amends the design's audit-table row: the character flows already own a refusal channel with better wording than the generic conflict banner; behaviour (Req 2.8/5.3) is unchanged and tested | | Q112 | 2026-08-21 | A generic-notes citation renders as an inert label; only entry citations navigate | Amends Req 5.2's letter: the generic notes live on the very page showing the fact, so navigation would go nowhere useful (Q10's rationale) |+| Q113 | 2026-08-22 | Pronoun candidates ("He", "his", "they") are folded by the prompt — the model is told to report such facts under the named character they refer to — and dropped by grounding as a backstop (`pronounName`, a fixed English personal-pronoun list compared against the name key) | The first real sweeps proposed pronouns as characters; "he" grounds in nearly every note so the 1.6 check cannot catch it, and host-side coreference does not exist, so the fold has to be the model's and the drop the host's. A character literally named "It" is lost to the rule; hand-creation (Q39) recovers it |+| Q114 | 2026-08-22 | Places and activities ("hotel", "sex") are excluded by the prompt only, with examples; no host-side noun stoplist | promoted to Decision 6 — the phone's model ignored the prompt |+| Q115 | 2026-08-22 | Grounding drops a candidate the source never writes with a capital letter (`nameNeverCapitalised`); any uppercase letter in any occurrence passes; names with no cased letters are exempt; a split component is held to the same rule | The note's own spelling is the one deterministic proper-name signal: 84/84 real-name occurrences in the 2026-08-08 export carry a capital, and the junk ("sex", "hotel", "general", "gun girl", "the weird lady") never does. Alternatives measured and rejected in Decision 6 |  --- @@ -472,6 +475,84 @@ schema publication). All resolved in the design rewrite → Q76–Q88.  --- +## Decision 6: Junk candidates are filtered by the note's own spelling++**Date**: 2026-08-22+**Status**: accepted — supersedes Q114's prompt-only stance (the prompt wording itself still ships)++### Context++The first sweeps on the phone proposed "sex", "hotel", and place names as+characters. Q114 answered with prompt wording only — the not-a-character list+gained places and activities with examples — on the Q55 theory that junk the+prompt can name is prompt-shaped. Installed on the phone, it changed nothing:+the same candidates came back. Pronouns had already been given a host-side+drop (Q113); the question was what deterministic rule, if any, could stand+behind the prompt for the rest.++A host-side harness (`prototype/junk-harness/`) ran the branch's prompt+against the on-device model over the 2026-08-08 export plus three synthetic+notes in the reader's register, and measured four approaches per candidate.+Findings are in `prototype/junk-harness-findings.md`.++### Decision++Grounding drops a candidate whose name the source never writes with an+uppercase letter — any letter, any occurrence; names with no cased letters+are exempt (Q115). Capitalised place names are left to the review flow.++### Rationale++The capitalisation rule is the only signal that was both deterministic and+clean on the data: every one of 84 real-name occurrences in the export carries+a capital, and every junk shape the harness produced ("general", "gun girl",+"student council girl", "the weird lady", "the other S-classers") has none —+the same shape as "sex" and "hotel". It costs nothing per candidate and+explains itself in the drop reason. The alternatives each failed on precision,+and a drop is unrecoverable short of hand-creation (Q39), so a rule that+sometimes kills a real character is worse than no rule.++### Alternatives Considered++- **Prompt wording only (Q114)**: rejected by observation — the phone's model+  kept proposing the same candidates. The Mac's model, notably, did *not*+  reproduce "sex"/"hotel" on the harness notes, so prompt tuning measured on+  the host says little about the phone.+- **Model self-classification** (a `kind` enum on `ExtractedCharacter`, drop+  non-`character`): correctly labelled "Brockton Bay" a place and "team" a+  group, but also labelled **Armsmaster** and "Dallons" places — real+  characters lost silently. It also tripped a guardrail refusal on a note the+  plain schema answered. Rejected as a hard drop.+- **`NLTagger` name-type tagging** (drop `placeName`): over all 178 notes it+  tagged 33 distinct spans as places, roughly half of them characters —+  Grover, Charlie, Bruce, Batman, Tanya, Lancelot, Tara, Allan, Timon.+  Rejected.+- **Real-world gazetteer** (countries, cities): would catch "Tokyo", "New+  York", "Japan", but the corpus already has "Phoenix" and "Savannah" as+  names, and fictional places ("Brockton Bay", "Liesse") are outside any+  list. Not built.+- **Requiring both model-`place` and tagger-`placeName`**: no real character+  got both labels in the sample, so the conjunction may be precise — but it+  needs the schema change and its refusal cost, on 11 notes of evidence.+  Deferred; the harness can re-measure it.++### Consequences++**Positive:**+- "sex", "hotel", unnamed roles and every other lowercase common noun never+  reach the review list, on any model version.+- The rule is explainable from the drop reason alone and testable without+  the model.++**Negative:**+- Capitalised places ("Tokyo", "Brockton Bay") still reach the review list;+  the skip is one tap and is remembered (Q8).+- A common noun that opens a sentence passes ("Sex was the whole chapter").+- A reader who writes every name in lowercase would lose all of them; the+  export shows this reader does not, and the rule is one line to relax.++---+ ## Review round 4 (2026-08-21) — delta: combine and the slash-split  Reader-driven scope change from the prototype findings (Decision 4, Decision
specs/character-extraction/design.md Modified +8 / -3
diff --git a/specs/character-extraction/design.md b/specs/character-extraction/design.mdindex 2899154..cf51e86 100644--- a/specs/character-extraction/design.md+++ b/specs/character-extraction/design.md@@ -107,14 +107,19 @@ oversized source is skipped, left uncovered, logged (Q35). `@Generable` DTOs exactly as prototyped (`prototype/Sources/main.swift`): `ExtractionResult` → `ExtractedCharacter` (name, facts) → `ExtractedFact` (statement, quote) — flat strings, no identifiers, greedy sampling. The prompt-asks for **named story characters only** (Q55). `CharacterExtractionModelClient`+asks for **named story characters only** (Q55), names places and activities as+non-characters (Q114), and tells the model to report a pronoun's fact under the+named character it refers to (Q113). `CharacterExtractionModelClient` protocol + stub mirror `RuleSuggestionModelClient` + its recorder stub; availability, refusal taxonomy, and `withKnownIssue` test handling reused as-is.  Grounding (deterministic, in AsterismIntelligence, shared with the prototype-harness): evidence span verbatim in cited source (case-insensitive, NFC),-candidate name present in a processed source, caps per+harness): candidate name not a pronoun (fixed list against the name key,+Q113), candidate name present in a processed source, written with a capital+letter somewhere in it (Q115, Decision 6 — the only deterministic+proper-name signal; capitalised places stay the review flow's job), evidence+span verbatim in cited source (case-insensitive, NFC), caps per `CharacterExtractionBounds` (Q54). Name key: trim → NFC → locale-free case fold (the `WorkTypeName.normalize` recipe, Q41) → strip one leading "the " (Q64).
specs/character-extraction/implementation.md Modified +3 / -2 (+ delta explanation)
diff --git a/specs/character-extraction/implementation.md b/specs/character-extraction/implementation.mdindex 50ab458..e30ef20 100644--- a/specs/character-extraction/implementation.md+++ b/specs/character-extraction/implementation.md@@ -73,8 +73,9 @@ Three layers, matching the package structure:   actor arbitrating all on-device model use (interactive asks a background   holder to yield — two-step, never a seizure); the FoundationModels   extraction client (greedy sampling, guided generation into-  `ExtractionResult`); grounding (verbatim-quote, name-presence, and length-  checks — over-long values are dropped, never truncated); the assembler+  `ExtractionResult`); grounding (pronoun drop, name-presence, never-+  capitalised drop, verbatim-quote, and length checks — over-long values are+  dropped, never truncated; Q113/Q115, Decision 6); the assembler   (slash-compound splitting into name + proposed aliases, canonical name   keys, Req 2.3 matching via the single `CharacterMatching` implementation);   and `CharacterExtractionLedger` (held proposals merged per name key per@@ -214,3 +215,121 @@ Three layers, matching the package structure: - **Missing / deferred to the user**: the three user-side prerequisites in   `prerequisites.md` — notably the Q86 publication run and a fresh Personal   backup before updating the daily-use install.++---++# Delta (2026-08-22): pronoun fold and the capitalisation backstop++Two post-merge commits (`1f5709b`, `cd6831a`) plus review fixes. Scope:+`CharacterGrounding`, the model client's instructions, their tests, Decision 6+/ Q113–Q115, and a host-side harness under `prototype/junk-harness*`.++## Beginner Level++### What Changed+The first real sweeps on the phone proposed things that are not characters:+pronouns ("He", "his", "they"), activities ("sex"), and places ("hotel",+"Tokyo"). Two changes address that. First, the instructions the on-device+model receives now say a pronoun is never a character — when the note says+"he", report the fact under the named character "he" stands for — and they+list places and activities as non-characters with examples. Second, the+grounding step (the host-side check that runs after the model answers) gained+two new rules: a candidate whose name is a pronoun is thrown away, and a+candidate whose name is never written with a capital letter anywhere in the+note is thrown away.++### Why It Matters+The model on the phone ignored the wording change — "sex" and "hotel" kept+coming back. Only a rule the app applies itself is reliable across model+versions. The capital-letter rule works because the reader writes every real+name with a capital (84 of 84 occurrences in the export) and writes junk like+"sex", "hotel", "the general" in lowercase.++### Key Concepts+- **Grounding**: the deterministic checks that discard model output the note+  does not support — like a fact-checker who only accepts claims they can+  find in the source.+- **Drop reason**: each discarded candidate is logged with the rule that+  removed it (`pronounName`, `nameNeverCapitalised`), so Console shows why.+- **Review flow**: what the rules miss still reaches the reader's review+  list, where a skip is one tap and remembered.++---++## Intermediate Level++### Changes Overview+- `FoundationCharacterExtractionModelClient.instructions`: pronoun attribution+  rule; places/activities named as non-characters (Q113, Q114).+- `CharacterGrounding`: `GroundingDrop.Reason` gains `pronounName` and+  `nameNeverCapitalised`; name rules are consolidated into one+  `dropReason(for:in:)` (empty → pronoun → length → presence) that the+  whole-name path and every slash-split component share; `presence(of:in:)`+  answers "in source" and "ever capitalised" in one case-insensitive scan.+- Tests: pronoun drops (case-insensitive, facts discarded, article-stripped+  keys, absent pronoun still `pronounName`), capitalisation (second-word and+  hyphenated capitals pass, uncased scripts exempt, sentence-initial common+  nouns pass), split cancellation on a lowercase or pronoun component.+- Spec: Q113/Q115 rows, Q114 promoted to Decision 6, design and+  implementation text updated; harness and findings under `prototype/`.++### Implementation Approach+The fold is the model's job and the drop is the host's: coreference ("who is+he?") needs the model's reading, so the prompt carries the attribution rule,+while the drop is mechanical so it holds whatever the model does. The pronoun+list is compared against the *name key* (after `CharacterNameKey.normalize`),+so "The It" keys to `it` and is dropped — consistent with the key being the+identity aliases and combines route on. The capitalisation test is "any cased+letter that is not lowercase, in any occurrence", which keeps "prince+Kyllian", "dean Ryu", "van Helsing" and titlecase digraphs.++### Trade-offs+Measured in the harness: a `kind` self-classification in the schema+mislabelled Armsmaster and Blob (real characters) and drew an extra guardrail+refusal; `NLTagger` called Grover, Bruce and Batman places; a gazetteer would+hit Phoenix and Savannah. Each would silently lose real characters, and a+drop is unrecoverable short of hand-creation, so none shipped. Cost of the+chosen rule: capitalised places still reach the review list, and a common+noun opening a sentence passes.++---++## Expert Level++### Technical Deep Dive+`presence(of:in:)` scans with `range(of:options:range:)` resuming from each+match's `upperBound`; the needle is NFC-composed once, the haystack is already+NFC from `ground`. Termination holds because the needle is non-empty on every+path (`dropReason` checks emptiness first) and Foundation returns nil for an+empty search string anyway. Total work is linear in haystack length plus+occurrence count, bounded by `maximumCandidates = 24` and the model's context+window, and runs on the `CharacterExtractor` actor behind an 8–24 s model+call — microseconds against seconds. Uncased needles short-circuit to+`.present` on first match. The split path's earlier hole — component+predicate lacking the pronoun check, so "He/Hanna" could ground as "He" with+alias "Hanna" — is closed by sharing `dropReason`.++### Architecture Impact+No public surface changes beyond two enum cases; the only consumer logs+`rawValue` generically, so nothing switches exhaustively. The harness is a+standalone package under `specs/`, not referenced by the Makefile, the+xcodeproj, or any `swift test` target; `.build/` is ignored at any depth.++### Potential Issues+- A reader who writes names in lowercase loses them all; the rule is one+  guard to relax and the export says this reader does not.+- Model-version drift: the Mac's model never produced "sex"/"hotel" on the+  same notes the phone did; prompt tuning measured on the host is weak+  evidence for the phone. The mechanical rules are what hold.+- Capitalised places ("Tokyo", "Brockton Bay") remain. The one untested idea+  — require *both* model-`place` and tagger-`placeName` — had no false+  positive in 27 notes but needs the schema change; deferred in Decision 6.++## Completeness Assessment (delta)++- **Fully implemented**: the two asks — pronoun candidates folded/dropped,+  lowercase places and activities excluded — with tests and spec records.+- **Partially implemented**: capitalised place names are excluded only when+  the model honours the prompt; otherwise they are a remembered skip.+- **Missing**: nothing against the ask; the conjunction rule is recorded as a+  follow-up, not a gap.
specs/character-extraction/prototype/junk-harness-findings.md Added +93 / -0
diff --git a/specs/character-extraction/prototype/junk-harness-findings.md b/specs/character-extraction/prototype/junk-harness-findings.mdnew file mode 100644index 0000000..e880096--- /dev/null+++ b/specs/character-extraction/prototype/junk-harness-findings.md@@ -0,0 +1,93 @@+# Junk-candidate harness — findings (2026-08-22)++Why: the phone's first sweeps proposed "sex", "hotel" and place names as+characters, and the prompt-only fix (Q114) changed nothing on the phone. This+harness measures what *can* catch them, against the on-device model on the+Mac, over the 2026-08-08 Personal export plus three synthetic notes written in+the reader's register (that export has no "sex" note and two "hotel" notes).+Harness: `junk-harness/` (`swift run JunkHarness <archive.json> [n] [all]`,+`swift run PlaceCensus <archive.json>`). Outcome: Decision 6 / Q115.++Per candidate the model produced, four signals:++- **A** — the branch's prompt and schema (baseline, after the pronoun drop)+- **B** — same prompt, schema gains `kind: character | place | thing | activity | group`+- **C** — is the name ever written with a capital letter in the note? (no model call)+- **D** — `NLTagger` name-type tag on the name's occurrences (no model call)++## Run 1 — 3 synthetic + 8 real notes matching place words (22 model calls)++| Note | A candidates (C = never capitalised in note) | B labels that differ from "character" |+|---|---|---|+| Alex Mack (synthetic: hotel, Tokyo, sex, Pentagon) | Willow, Jack, Terawatt | — |+| Worm (synthetic: school, Brockton Bay, Dallons) | Taylor, Emma, Sophia, Amy, Brockton Bay, Armsmaster | Brockton Bay **place**, **Armsmaster place**, **Dallons place** |+| PGtE (synthetic: sex, Liesse, tavern) | Catherine, Kilian, Black, Masego | B call **refused by guardrail** (A was not) |+| The Eldest Daughter Takes Over | Bellady, Prince Kyllian | — |+| Hiding a Warehouse in the Apocalypse | Jeong-Gyeom | — |+| The Overpowered Support | Ho Kim, Yeonwha, **student council girl** (C), **gun girl** (C) | — |+| Welcome to the Dungeon Hotel (1) | Jeonghyo, **the weird lady** (C), **the other S-classers** (C) | — |+| Welcome to the Dungeon Hotel (2) | Yeonghyo, Dokkaebi | — |+| Alex Mack (real, 3 notes) | Terawatt, Jack, Hanna, Riley, Graham, Jo Lupo, **general** (C) | team **group** |++Observations:++1. **The Mac's model did not reproduce "sex"/"hotel"** on these notes, even+   the synthetic ones built to provoke it. The phone's model does. Prompt+   changes measured on the host therefore say little about the phone, which+   is why the fix had to be mechanical.+2. **C separates every real name from every junk candidate in the sample.**+   The five junk shapes A produced are all lowercase-only in the note; every+   real name carries a capital. "Prince Kyllian" is written "prince Kyllian"+   in its note — hence the rule is *any uppercase letter, any occurrence*,+   not first-letter-of-every-word.+3. **B is not safe as a hard drop.** It got "Brockton Bay" and "team" right+   and mislabelled **Armsmaster** (a real character) and "Dallons" as places.+   It also drew a guardrail refusal on a note the plain schema answered —+   the `kind` field changes what the model generates, and refusals are+   already 11% (Decision 3).+4. **D is not safe either** — see the census below.++## Capitalisation census over the whole export (no model)++For 27 character names known from the prototype and this run, every+word-bounded occurrence in the 178 noted entries: **84 occurrences, 84 with a+capital letter**. (The two lowercase "black" hits are the colour, in a work+where Black is not a character.) The reader capitalises names.++## NLTagger place census over the whole export (no model)++`PlaceName`, 33 distinct spans. Real places: New York, Japan, Korea, Tokyo,+FL, New Jersey, US, Rome, Beirut, China, Darjeeling, Cossack, Avalon. Tagged+as places but characters or story names: **Grover, Charlie, Bruce, Batman,+Tanya, Lancelot, Tara, Allan, Timon**, plus fictional names (Idnia, Nyxalia,+Eutiah, Jansae, Dalrae, Geb, Orchis) whose kind the tagger cannot know.+`OrganizationName` is no better (Terawatt, Jeonghyo, Blob, Bane, Lance).+A tagger-based drop would lose roughly one real character in three it fired+on.++## Run 2 — 3 synthetic + 24 real notes in capture order (54 model calls)++59 baseline candidates. Junk among them: "monster", "kimchi4life" (a+commenter's handle), "Monarchs" (a faction), "Blob" (a creature — arguably a+character), "MC" (the reader's shorthand for the protagonist, capitalised,+passes — and should: it is how this reader names an unnamed lead).++- **C, as shipped (any uppercase letter, any occurrence)**: drops "monster"+  and "kimchi4life"; keeps "dean Ryu" (written lowercase-first in its note)+  and "prince Kyllian". Zero real names lost. The harness's own C column is+  first-letter-only and therefore over-reports — it is the comparison that+  showed why the rule had to be any-letter.+- **B**: labelled "Monarchs" group (right), "Blob" **thing** twice (a+  creature the reader tracks as a character — wrong as a drop), and again+  "Armsmaster" place. One guardrail refusal on a note the plain schema+  answered. Confirms run 1: not a hard drop.+- Timings pooled over both variants: median ≈ 8 s, max ≈ 24 s per request —+  the same envelope as Decision 3.++## What is left uncaught++Capitalised place names — "Tokyo", "Brockton Bay". No deterministic rule was+found that catches them without losing real characters. The one candidate+worth re-measuring is the *conjunction* B-says-place **and** D-says-place:+in run 1 no real character got both labels. It needs the schema change and+its refusal cost, so it is deferred (Decision 6).
specs/character-extraction/prototype/junk-harness/Sources/main.swift Added +265 / -0
diff --git a/specs/character-extraction/prototype/junk-harness/Sources/main.swift b/specs/character-extraction/prototype/junk-harness/Sources/main.swiftnew file mode 100644index 0000000..fae00bb--- /dev/null+++ b/specs/character-extraction/prototype/junk-harness/Sources/main.swift@@ -0,0 +1,265 @@+// Throwaway harness — see Package.swift. Compares, per note:+//   A  shipped prompt + shipped schema (baseline, as on the branch)+//   B  shipped prompt + a `kind` self-classification field in the schema+//   C  A's output with a "never capitalised in the note" drop   (no model call)+//   D  A's output with NLTagger place/org tagging                (no model call)+//+// Usage: swift run JunkHarness <archive.json> [maxMatching]++import Foundation+import FoundationModels+import NaturalLanguage++struct Archive: Decodable {+    let payload: Payload+    struct Payload: Decodable { let entries: [Entry]; let works: [Work] }+    struct Entry: Decodable { let id: String; let note: String; let workID: String?; let firstCapturedAt: String }+    struct Work: Decodable { let id: String; let displayTitle: String; let genericNotes: String }+}++// MARK: - Schemas++@Generable+struct ExtractedFact {+    @Guide(description: "One short statement about the character, in third person.")+    var statement: String+    @Guide(description: "The exact words from the note this statement comes from, copied verbatim. Never paraphrase.")+    var quote: String+}++@Generable+struct ExtractedCharacter {+    @Guide(description: "The character's name exactly as the note spells it.")+    var name: String+    @Guide(description: "Facts the note states about this character. Empty if the note only mentions the name.")+    var facts: [ExtractedFact]+}++@Generable+struct ExtractionResult {+    @Guide(description: "Named story characters this note mentions. Empty if it names none.")+    var characters: [ExtractedCharacter]+}++@Generable+enum CandidateKind {+    case character+    case place+    case thing+    case activity+    case group+}++@Generable+struct KindedCharacter {+    @Guide(description: "The character's name exactly as the note spells it.")+    var name: String+    @Guide(description: "What this name actually refers to: character (a person or being in the story), place (a location or building), thing (an object), activity (an action or event), or group (a crowd, team, family or organisation).")+    var kind: CandidateKind+    @Guide(description: "Facts the note states about this character. Empty if the note only mentions the name.")+    var facts: [ExtractedFact]+}++@Generable+struct KindedResult {+    @Guide(description: "Named story characters this note mentions. Empty if it names none.")+    var characters: [KindedCharacter]+}++// MARK: - Prompt (verbatim from the branch)++let instructions = """+You extract named story characters from a reader's private note about one \+chapter of a serial story. The note is informal and may be short.++Report only characters the note itself names. Use no outside knowledge of \+any story. Never invent a character, a name, or a fact.++A character is a person or being in the story who is referred to by a name. \+These are not characters: the reader, the author, groups and crowds \+("everyone", "the crew"), unnamed roles ("the general", "the innkeeper"), \+places ("the hotel", "New York"), objects, activities and events ("sex", \+"the fight", "dinner"), organisations, and abstractions. If the note names \+no characters, return an empty list.++A pronoun is never a character. When the note says "he", "she", "they", \+"his", "her", "their" or "it", work out which named character the word \+refers to and report the fact under that character's name. If you cannot \+tell which named character a pronoun refers to, leave that fact out.++Spell each name exactly as the note spells it, character for character.++For every fact, copy the supporting words out of the note into the quote \+field verbatim — the same characters, in the same order, with the same \+spelling, punctuation and capitalisation. Do not paraphrase, translate, \+correct or shorten them. A fact you cannot support with the note's own \+words is a fact you must not report. A character the note only mentions by \+name is reported with no facts.+"""++func prompt(title: String, note: String) -> String {+    """+    The story is titled "\(title)". The reader's note about one chapter:++    \(note)++    List the named story characters this note mentions, with any facts it \+    states about them.+    """+}++// MARK: - Grounding (name present + pronoun drop, as on the branch)++let pronouns: Set<String> = [+    "i", "me", "my", "mine", "myself", "you", "your", "yours", "yourself", "yourselves",+    "he", "him", "his", "himself", "she", "her", "hers", "herself", "it", "its", "itself",+    "we", "us", "our", "ours", "ourselves", "they", "them", "their", "theirs", "themselves",+]++func key(_ s: String) -> String {+    var k = s.trimmingCharacters(in: .whitespacesAndNewlines).precomposedStringWithCanonicalMapping+        .folding(options: [.caseInsensitive], locale: nil)+    while k.hasPrefix("the ") { k.removeFirst(4); k = k.trimmingCharacters(in: .whitespaces) }+    return k+}++func grounds(_ name: String, in text: String) -> Bool {+    let n = name.trimmingCharacters(in: .whitespacesAndNewlines)+    guard !n.isEmpty, !pronouns.contains(key(n)) else { return false }+    return text.range(of: n, options: [.caseInsensitive]) != nil+}++/// C: does any occurrence of the name in the note start with an uppercase letter?+func everCapitalised(_ name: String, in text: String) -> Bool {+    var search = text.startIndex+    while let r = text.range(of: name, options: [.caseInsensitive], range: search..<text.endIndex) {+        if let first = text[r].first, first.isUppercase { return true }+        search = r.upperBound+    }+    return false+}++/// D: NLTagger name-type tag overlapping any occurrence of the name.+func nlTag(_ name: String, in text: String) -> String {+    let tagger = NLTagger(tagSchemes: [.nameType])+    tagger.string = text+    var found: [String] = []+    var search = text.startIndex+    while let r = text.range(of: name, options: [.caseInsensitive], range: search..<text.endIndex) {+        tagger.enumerateTags(in: r, unit: .word, scheme: .nameType,+                             options: [.omitWhitespace, .omitPunctuation, .joinNames]) { tag, _ in+            if let tag { found.append(tag.rawValue) }+            return true+        }+        search = r.upperBound+    }+    return found.isEmpty ? "-" : Set(found).sorted().joined(separator: "/")+}++// MARK: - Synthetic probes in the notes' register++let synthetic: [(String, String)] = [+    ("The Secret Return of Alex Mack", """+    Alex and Willow end up at the hotel in Tokyo again. Lots of sex in this one, which the author \+    handles fine but it drags. Jack calls from the Pentagon about the silicates. Terawatt shows up \+    at the end and the fight is short.+    """),+    ("Worm", """+    Taylor goes to the school and gets cornered by Emma and Sophia in the bathroom. Brockton Bay \+    is as grim as ever. Then dinner at the Dallons, Amy being awkward about it. Armsmaster on the \+    news.+    """),+    ("A Practical Guide to Evil", """+    Catherine and Kilian have sex, then the Legion marches on Liesse. Black shows up at the tavern \+    and lectures her about the Empire. Masego fiddles with the gate.+    """),+]++// MARK: - Run++struct Row { var name: String; var kind: String; var facts: Int }++@main+struct Harness {+    static func main() async throws {+        let args = CommandLine.arguments+        guard args.count >= 2 else { fatalError("usage: <archive.json> [maxMatching]") }+        let maxMatching = args.count >= 3 ? Int(args[2]) ?? 8 : 8+        let archive = try JSONDecoder().decode(Archive.self, from: Data(contentsOf: URL(fileURLWithPath: args[1])))+        let model = SystemLanguageModel.default+        guard model.isAvailable else { fatalError("model unavailable: \(model.availability)") }+        let titles = Dictionary(uniqueKeysWithValues: archive.payload.works.map { ($0.id, $0.displayTitle) })++        let words = ["hotel", "tokyo", "new york", "pentagon", "london", "school", "hospital", "city", "bar"]+        var sources: [(String, String)] = synthetic+        for e in archive.payload.entries.sorted(by: { $0.firstCapturedAt < $1.firstCapturedAt })+        where !e.note.isEmpty && e.note.count < 1500 {+            guard sources.count < synthetic.count + maxMatching else { break }+            let lower = e.note.lowercased()+            let all = args.count >= 4 && args[3] == "all"+            if all || words.contains(where: { lower.contains($0) }) {+                sources.append((titles[e.workID ?? ""] ?? "Untitled", e.note))+            }+        }+        print("Sources: \(sources.count) (\(synthetic.count) synthetic)")++        var report = "# Junk-candidate harness — \(Date.now.formatted(.iso8601))\n\n"+        var stats = (aJunkLower: 0, aTotal: 0, bNonCharacter: 0, bTotal: 0, bCharLower: 0)+        var timings: [Double] = []++        for (i, (title, note)) in sources.enumerated() {+            report += "## \(i + 1). \(title)\(i < synthetic.count ? " (synthetic)" : "")\n\n> \(note.replacingOccurrences(of: "\n", with: " "))\n\n"+            // A+            var a: [Row] = []+            let sA = LanguageModelSession(model: model, instructions: instructions)+            let t0 = ContinuousClock.now+            do {+                let r = try await sA.respond(to: prompt(title: title, note: note), generating: ExtractionResult.self,+                                             options: GenerationOptions(sampling: .greedy))+                a = r.content.characters.filter { grounds($0.name, in: note) }.map { Row(name: $0.name, kind: "?", facts: $0.facts.count) }+            } catch { report += "A error: \(error)\n\n" }+            timings.append(seconds(since: t0))+            // B+            var b: [Row] = []+            let sB = LanguageModelSession(model: model, instructions: instructions)+            let t1 = ContinuousClock.now+            do {+                let r = try await sB.respond(to: prompt(title: title, note: note), generating: KindedResult.self,+                                             options: GenerationOptions(sampling: .greedy))+                b = r.content.characters.filter { grounds($0.name, in: note) }.map { Row(name: $0.name, kind: "\($0.kind)", facts: $0.facts.count) }+            } catch { report += "B error: \(error)\n\n" }+            timings.append(seconds(since: t1))++            report += "| A name | facts | C ever-cap | D NLTagger |\n|---|---|---|---|\n"+            for row in a {+                let cap = everCapitalised(row.name, in: note)+                stats.aTotal += 1+                if !cap { stats.aJunkLower += 1 }+                report += "| \(row.name) | \(row.facts) | \(cap ? "yes" : "**no**") | \(nlTag(row.name, in: note)) |\n"+            }+            report += "\n| B name | kind | facts |\n|---|---|---|\n"+            for row in b {+                stats.bTotal += 1+                if row.kind != "character" { stats.bNonCharacter += 1 }+                else if !everCapitalised(row.name, in: note) { stats.bCharLower += 1 }+                report += "| \(row.name) | \(row.kind == "character" ? row.kind : "**\(row.kind)**") | \(row.facts) |\n"+            }+            report += "\n"+            print("  \(i + 1)/\(sources.count) \(title): A=\(a.map(\.name)) B=\(b.map { "\($0.name):\($0.kind)" })")+        }++        let sorted = timings.sorted()+        report += "## Summary\n\n"+        report += "- A candidates: \(stats.aTotal), never-capitalised (C would drop): \(stats.aJunkLower)\n"+        report += "- B candidates: \(stats.bTotal), labelled non-character (B would drop): \(stats.bNonCharacter); labelled character but never capitalised: \(stats.bCharLower)\n"+        report += "- Timings (both variants pooled): median \(String(format: "%.1f", sorted[sorted.count / 2]))s, max \(String(format: "%.1f", sorted.last ?? 0))s\n"+        let dest = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent("junk-harness-report.md")+        try report.write(to: dest, atomically: true, encoding: .utf8)+        print("Report: \(dest.path)")+    }++    static func seconds(since start: ContinuousClock.Instant) -> Double {+        let d = ContinuousClock.now - start+        return Double(d.components.seconds) + Double(d.components.attoseconds) / 1e18+    }+}
specs/character-extraction/prototype/junk-harness/Census/main.swift Added +39 / -0
diff --git a/specs/character-extraction/prototype/junk-harness/Census/main.swift b/specs/character-extraction/prototype/junk-harness/Census/main.swiftnew file mode 100644index 0000000..62da75e--- /dev/null+++ b/specs/character-extraction/prototype/junk-harness/Census/main.swift@@ -0,0 +1,39 @@+// No model: what does NLTagger call a PlaceName / OrganizationName across+// every note in the archive? Precision check for using it as a drop rule.+import Foundation+import NaturalLanguage++struct Archive: Decodable {+    let payload: Payload+    struct Payload: Decodable { let entries: [Entry] }+    struct Entry: Decodable { let note: String }+}++let path = CommandLine.arguments[1]+let archive = try JSONDecoder().decode(Archive.self, from: Data(contentsOf: URL(fileURLWithPath: path)))+var places: [String: Int] = [:]+var orgs: [String: Int] = [:]+var people: [String: Int] = [:]+for entry in archive.payload.entries where !entry.note.isEmpty {+    let text = entry.note+    let tagger = NLTagger(tagSchemes: [.nameType])+    tagger.string = text+    tagger.enumerateTags(in: text.startIndex..<text.endIndex, unit: .word, scheme: .nameType,+                         options: [.omitWhitespace, .omitPunctuation, .joinNames]) { tag, range in+        let span = String(text[range])+        switch tag {+        case .placeName?: places[span, default: 0] += 1+        case .organizationName?: orgs[span, default: 0] += 1+        case .personalName?: people[span, default: 0] += 1+        default: break+        }+        return true+    }+}+func dump(_ title: String, _ d: [String: Int]) {+    print("== \(title) (\(d.count) distinct)")+    for (k, v) in d.sorted(by: { $0.value > $1.value }) { print("  \(v)\t\(k)") }+}+dump("PlaceName", places)+dump("OrganizationName", orgs)+print("== PersonalName distinct: \(people.count); top: \(people.sorted { $0.value > $1.value }.prefix(15).map { "\($0.key)(\($0.value))" })")
specs/character-extraction/prototype/junk-harness/Package.swift Added +14 / -0
diff --git a/specs/character-extraction/prototype/junk-harness/Package.swift b/specs/character-extraction/prototype/junk-harness/Package.swiftnew file mode 100644index 0000000..786a6ca--- /dev/null+++ b/specs/character-extraction/prototype/junk-harness/Package.swift@@ -0,0 +1,14 @@+// swift-tools-version: 6.0+// Throwaway harness: compares junk-candidate handling approaches for+// character extraction against the real on-device model over an exported+// archive. Standalone on purpose, like the spec's prototype.+import PackageDescription++let package = Package(+    name: "JunkHarness",+    platforms: [.macOS("26.0")],+    targets: [+        .executableTarget(name: "JunkHarness", path: "Sources"),+        .executableTarget(name: "PlaceCensus", path: "Census")+    ]+)

Things to double-check

Phone behaviour vs host evidence.

The Mac's model never produced "sex"/"hotel" on notes the phone did. The capitalisation rule is model-independent, but confirm on the phone after install — the reader reported "much better, not many false positives" on the previous build; the review fixes change no outcome except closing the split hole.

Accepted characters persist.

Characters already accepted into the dev library from earlier sweeps are untouched by any grounding change and sync via CloudKit; they need deleting by hand.

Lowercase-name readers.

The rule assumes names are capitalised somewhere in the note. One guard in presence(of:in:) relaxes it if a future corpus disagrees.