@tznthou/ccrecall 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,835 @@
1
+ #!/usr/bin/env python3
2
+ """L3 measurement — can a reader act on the part of a memory that survives the
3
+ per-row truncation applied before startup/prompt injection?
4
+
5
+ Background: token-budget.ts truncates each injected row to DEFAULT_PER_ROW_CHAR_CAP
6
+ chars. If the "what to do next" part of a memory sits past that cut, the memory is
7
+ delivered as a symptom report with no prescription — surfaced, but not actionable.
8
+
9
+ Usage:
10
+ python3 scripts/l3-prescription-position.py [--days 30 | --since YYYY-MM-DD]
11
+ [--until YYYY-MM-DD] [--cap 149]
12
+ python3 scripts/l3-prescription-position.py --test # locator unit tests, no DB
13
+ python3 scripts/l3-prescription-position.py --self-check [PATH] # score locator vs human labels
14
+
15
+ DB path: $CCRECALL_DB_PATH, else ~/.ccrecall/ccrecall.db. So the no-arg default
16
+ IS the production database — what this avoids is the Tier 0 acceptance script's
17
+ `:27` defect of making that the ONLY reachable path. The resolved path is printed
18
+ on every run, so measuring production is visible rather than silent.
19
+
20
+ Exit codes are about trustworthiness, not completion: 0 only when the numbers can
21
+ be used as evidence, 1 when the script printed a reason they cannot be (empty
22
+ window, no locatable samples, counter-probe failed, locator possibly destroyed),
23
+ 2 on bad arguments or a missing DB.
24
+
25
+ ────────────────────────────────────────────────────────────────────────────────
26
+ 2026-09-12 — WHY THERE ARE NOW TWO LOCATORS
27
+
28
+ The marker locator below (Mitigation:/Solution:/解法: …) was the original
29
+ instrument. It measured the extraction prompt change of PR #107 as **worse than
30
+ no change**: over the whole post-change window it puts a prescription inside the
31
+ cap for 0 of 17 locatable rows (0.0%), against 1 of 54 (1.9%) for the pre-change
32
+ window. Read alone, it says #107 made things worse.
33
+
34
+ Two blind human reads of 79 rows from those same two windows — labels fixed
35
+ before any row was read, arms mixed and hidden, two independent draws — found
36
+ 28/40 post-change against 13/39 pre-change (Fisher exact p = 0.0016).
37
+
38
+ The marker locator was not merely imprecise, it was answering a different
39
+ question. #107 asked the model to lead with the prescription — and a memory whose
40
+ first sentence IS the prescription has no reason to carry a "Mitigation:" label.
41
+ 23 of the 28 post-change hits carried no marker anywhere in the row. The
42
+ instrument's signal fell exactly because the change worked.
43
+
44
+ Hence: the actionability probe is the primary metric, the marker locator is kept
45
+ as a legacy cross-check, and the two are always printed side by side. When they
46
+ disagree in the way described above, that disagreement is itself the finding.
47
+ The probe approximates a human judgement with regular expressions and WILL be
48
+ wrong on individual rows; --self-check exists so that its error rate against
49
+ human labels is a number this script prints rather than a thing you assume.
50
+ """
51
+ import argparse
52
+ import datetime
53
+ import json
54
+ import os
55
+ import pathlib
56
+ import re
57
+ import sqlite3
58
+ import statistics
59
+ import sys
60
+ from typing import NamedTuple, Optional
61
+
62
+ # token-budget.ts DEFAULT_PER_ROW_CHAR_CAP = 150; truncateToChars keeps
63
+ # maxChars - 1 chars and appends an ellipsis, so 149 is what actually survives.
64
+ # Duplicated rather than read from the TypeScript source: parsing TS from Python
65
+ # to recover one integer is more failure surface than the drift it prevents, and
66
+ # it would fail silently. The TS side is the SSOT — if this ever disagrees with
67
+ # token-budget.ts, that file wins; tests/extraction-prompt-prescription.test.ts
68
+ # imports the constant live and will catch a change there.
69
+ DEFAULT_CAP = 149
70
+
71
+ # Default location of the human-labelled set used by --self-check. Under the
72
+ # repo's git-ignored .claude/, resolved from __file__ so it does not depend on the
73
+ # caller's cwd. Deliberately not in the repo proper: the labels are keyed by
74
+ # memory id, and resolving them needs the local production DB, whose contents
75
+ # carry private project names and paths. Deliberately not under ~/.claude/ either
76
+ # — this project's first rule is that it never writes there, and a file it reads
77
+ # by default from that tree invites the next reader to assume the rule has holes.
78
+ DEFAULT_ANNOTATIONS = str(pathlib.Path(__file__).resolve().parent.parent / ".claude" / "l3-annotation-set.json")
79
+
80
+ # ── Legacy locator: explicit prescription labels ────────────────────────────────
81
+ # Markers harvested from the corpus itself via a frequency scan, not written from
82
+ # memory. Re-run that scan before trusting this list on a materially different corpus.
83
+ #
84
+ # 2026-09-12 scan of the 279 uncompressed memories since 09-01 found the CJK half
85
+ # of this list is effectively dead code — 必須 0 hits, 別 0, 一律 1, 要 2 — because
86
+ # extraction writes English. Kept anyway: it costs nothing and a locale change
87
+ # would revive it. Do not read a CJK hit count of ~0 as "CJK memories have no
88
+ # prescriptions"; it means there are almost no CJK memories.
89
+ PRESCRIPTION_MARKERS = (
90
+ "Mitigation|Solution|Fix|Prevention|Correct approach|Workaround|Remedy|"
91
+ "Detection method|Correction|Lesson|How to apply|Rule|解法|修法|判準"
92
+ )
93
+ # Punctuation classes matter more than they look on a Traditional-Chinese corpus:
94
+ # a marker introduced by a fullwidth colon, or preceded by 、,;!? rather than a
95
+ # period, used to fall outside the pattern entirely. Every such miss landed in the
96
+ # "no locatable prescription" bucket, which is also what a *successful* rewrite
97
+ # looks like — so the blind spot bent the number in the direction of the change
98
+ # being measured. Leading bullets are allowed for the same reason.
99
+ _SEP = r"[.,;:!?。,、;!?]"
100
+ RX_MARKER = re.compile(
101
+ r"(?:^\s*(?:[-*]\s*)?|" + _SEP + r"\s*)(" + PRESCRIPTION_MARKERS + r")\s*[::]",
102
+ re.M | re.I,
103
+ )
104
+
105
+ # ── Primary locator: actionability probe ────────────────────────────────────────
106
+ # Verb list is evidence-based, not imagined: every entry either appeared >= 4 times
107
+ # as the first word after a comma/dash in the 2026-09-01.. corpus (verify 17,
108
+ # check 13, grep 10, run 6, ask 4, use 4, count 4, test 8) or was the operative
109
+ # verb of a row a human labelled "actionable" in the 2026-09-12 blind read
110
+ # (measure, compress, place, establish, override, keep, complete, record, treat).
111
+ #
112
+ # To re-harvest on a different corpus:
113
+ # split content on sentence enders and on /[,,—–-]\s+/, count the leading
114
+ # [a-z]{3,} token of each fragment, then keep the bare-infinitive verbs.
115
+ # Adding verbs from intuition rather than from that scan is how this becomes a
116
+ # list that matches everything and discriminates nothing.
117
+ ACTION_VERBS = (
118
+ "verify|check|grep|run|ask|use|count|test|measure|compress|place|establish|"
119
+ "override|keep|complete|record|treat"
120
+ )
121
+ # An imperative sits at the start of a sentence, or after the comma that closes a
122
+ # leading "When …"/"Before …" clause — which the corpus scan showed is where this
123
+ # codebase's prescriptions actually live ("When adding code next to a test-guarded
124
+ # path, verify the test's scope …").
125
+ #
126
+ # The trailing guard is `(?![\w.-])`, not `\b`: this corpus is full of tool names
127
+ # built from these very verbs, and a comma-separated list of them looks exactly
128
+ # like a comma-separated list of instructions: "the three scripts
129
+ # (check-links.sh, verify-schema.py) scan the tree" scored as actionable under
130
+ # `\b`, because `check` sat after ", " and ended at a hyphen, which `\b` accepts.
131
+ # A verb followed by `-` or `.` is a filename or a flag, not an instruction. The
132
+ # dot has one side effect worth knowing: a sentence-final imperative ("…, verify.")
133
+ # is dropped. Measured at 0 affected rows in the live corpus, so it stays rather
134
+ # than being traded back for filename false positives.
135
+ _IMPERATIVE_HEAD = r"(?:^|(?<=[.!?。!?])\s|(?<=[,,::—–])\s|(?<=\n))"
136
+ RX_IMPERATIVE = re.compile(
137
+ _IMPERATIVE_HEAD + r"(?:always\s+|first[,]?\s+)?(" + ACTION_VERBS + r")(?![\w.-])",
138
+ re.M | re.I)
139
+ # Obligation and prohibition. `never|cannot` are deliberately NOT here: in this
140
+ # corpus they describe behaviour ("CodeRabbit never produces findings", "the
141
+ # scripts scan project memory only, never global files") about as often as they
142
+ # forbid it, and both readings look identical to a regex. They are scored as WEAK.
143
+ RX_OBLIGATION = re.compile(r"\b(must|should|do not|don't|never\s+(?:" + ACTION_VERBS + r"))\b", re.I)
144
+ # WEAK signals: real prescriptions hide here, but so do plain statements of fact.
145
+ # Counting them separately is what turns one misleading number into an interval.
146
+ RX_WEAK = re.compile(r"\b(never|cannot|can't|instead of|rather than|not just|not only)\b", re.I)
147
+
148
+ # Quoted spans are masked before the STRONG pass. A quoted imperative is usually an
149
+ # example being discussed, not an instruction being given: a row reading
150
+ # `the rule "check the cache first" stopped working when …` is a symptom report,
151
+ # and human labelling treated it as one.
152
+ #
153
+ # The single-quote arm needs the word-boundary guards. Without them `'[^']{2,}'`
154
+ # treats the apostrophes in ordinary English as quote delimiters: in "…the
155
+ # entity's ID is runtime-generated (unsuitable for static manifest), don't change
156
+ # the lookup direction" it masks everything from `'s` to `don'`, swallowing the
157
+ # prohibition this probe exists to find. That row was a false negative against
158
+ # the human labels until the guards went in.
159
+ RX_QUOTED = re.compile(r"`[^`]*`|\"[^\"]*\"|「[^」]*」|(?<!\w)'[^']{2,}'(?!\w)")
160
+
161
+
162
+ def mask_quotes(text: str) -> str:
163
+ """Replace quoted spans with spaces, preserving offsets so positions stay true."""
164
+ return RX_QUOTED.sub(lambda m: " " * len(m.group(0)), text)
165
+
166
+
167
+ class Signals(NamedTuple):
168
+ """Where the first STRONG and first WEAK signal sit, plus the text after the
169
+ STRONG one so a caller can ask whether anything followed it inside the cap."""
170
+ strong_start: Optional[int]
171
+ strong_end: Optional[int]
172
+ weak_start: Optional[int]
173
+ weak_end: Optional[int]
174
+ tail: str
175
+
176
+
177
+ # An instruction is not delivered by its verb alone. "…, must" and "…, verify"
178
+ # both end at 148 with a cap of 149, and the reader gets the word with nothing to
179
+ # apply it to — no object, and in the first case not even the negation. So a verb
180
+ # counts as delivered only when some of what follows it also survives the cut.
181
+ #
182
+ # Three characters is a judgement, not a measurement: the shortest run that can
183
+ # carry a word rather than a fragment ("it", "DB", "all"). Its effect on the live
184
+ # corpus was measured before it went in, and the honest answer is ZERO: across the
185
+ # 430 rows of the 30-day window the delivered count is 91 at every threshold from
186
+ # 0 to 8, and 91 under the old verb-end-only rule too. No stored row currently has
187
+ # a verb straddling the cut. So this guard changes no number reported today — it
188
+ # is defensive, kept because the failure is real (reproducible on synthetic input,
189
+ # see TEST_CASES) and because when it did occur it would be invisible.
190
+ # Re-measure before changing it rather than reasoning about it.
191
+ MIN_OBJECT_CHARS = 3
192
+
193
+ # Cap on how many raw memory excerpts --self-check prints in one run.
194
+ MAX_MISSES_PRINTED = 12
195
+
196
+
197
+ def probe(text: str) -> Signals:
198
+ """Locate the first actionable instruction.
199
+
200
+ STRONG ignores quoted spans; WEAK does not. Both carry an end offset, because
201
+ a signal that merely starts inside the cap can still be cut in half — the
202
+ reason `delivered()` exists rather than a comparison written out at each call
203
+ site. The three call sites used to write it out, in three different ways.
204
+ """
205
+ masked = mask_quotes(text)
206
+ hits = [(m.start(1), m.end(1))
207
+ for m in (RX_IMPERATIVE.search(masked), RX_OBLIGATION.search(masked)) if m]
208
+ s_start, s_end = min(hits) if hits else (None, None)
209
+ w = RX_WEAK.search(text)
210
+ return Signals(s_start, s_end, w.start(1) if w else None, w.end(1) if w else None,
211
+ text[s_end:] if s_end is not None else "")
212
+
213
+
214
+ def delivered(sig: Signals, cap: int) -> bool:
215
+ """Did an instruction, and enough of what it acts on, survive truncation?"""
216
+ if sig.strong_end is None or sig.strong_end > cap:
217
+ return False
218
+ return len(sig.tail[: cap - sig.strong_end].strip()) >= MIN_OBJECT_CHARS
219
+
220
+
221
+ # ── Self-test: synthetic strings only, never production rows ───────────────────
222
+ # Each case is a failure this probe was seen to make, or a distinction it has to
223
+ # hold. Synthetic on purpose — a fixture cut from real memories would put private
224
+ # project names and paths in a public repo.
225
+ TEST_CASES = [
226
+ # (text, expect_strong_within_cap, why)
227
+ ("When adding code next to a test-guarded path, verify the test's scope covers it.",
228
+ True, "imperative after a leading When-clause — the corpus's dominant shape"),
229
+ ("Measure RESUME.md health in bytes, never line count.",
230
+ True, "sentence-initial imperative"),
231
+ ("Canary strings must be literal excerpts copied from the file.",
232
+ True, "obligation modal"),
233
+ ("When a mutation survives, do not assume the mutation broke the behaviour.",
234
+ True, "prohibition"),
235
+ ("Harness effect on model output is an independent variable across CLI providers.",
236
+ False, "a finding with no instruction in it"),
237
+ ("The merge gate blocks PRs waiting for review, but the bot never produces findings.",
238
+ False, "`never` describing behaviour, not forbidding it — the WEAK bucket"),
239
+ ("The three scripts scan project memory only, never global files.",
240
+ False, "same trap, third-person verb after never"),
241
+ ('The guard decayed when the format changed: the rule "check the cache first" stopped firing.',
242
+ False, "imperative inside a quoted example is not an instruction"),
243
+ ("Knowledge scope is three-layer, not two: project, tool, global.",
244
+ False, "contrast without a verb is not a prescription"),
245
+ ("The guard had two vulnerabilities. First, the pattern only matched paths.",
246
+ False, "`first` followed by a noun, not by an imperative"),
247
+ ("When an entity's ID is runtime-generated, don't change the lookup direction.",
248
+ True, "apostrophes in ordinary English must not be read as quote delimiters"),
249
+ ("The three scripts (check-links.sh, verify-schema.py, run-audit.sh) scan the tree.",
250
+ False, "a verb inside a filename is not an instruction"),
251
+ ("x" * 142 + ". must not delete the database.",
252
+ False, "modal ends at 148 of 149: the reader gets `. must ` — verb, no object, "
253
+ "not even the negation"),
254
+ ("z" * 140 + ". verify the cache invalidation path.",
255
+ False, "same shape with an imperative: `. verify ` alone is not actionable"),
256
+ ]
257
+
258
+
259
+ # Argument validation cases. Every one of these was a real defect: the window
260
+ # flags accepted values that made the filter a no-op rather than an error.
261
+ ARG_CASES = [
262
+ # (kwargs, expect_error, why)
263
+ (dict(since="2026-09-09"), False, "a well-formed --since is accepted"),
264
+ (dict(since="2026-09-09"), True,
265
+ "fullwidth digits: \\d is Unicode-aware, and BINARY collation then sorts them "
266
+ "above every ASCII date, turning the predicate into a no-op"),
267
+ (dict(until="2026-09-09"), True, "same for --until"),
268
+ (dict(since="2026-13-45"), True, "shape is right, calendar date does not exist"),
269
+ (dict(until="2026-02-30"), True, "same, and February makes it look plausible"),
270
+ (dict(since="2026-09-09", until="2026-09-09"), True, "empty window: until is exclusive"),
271
+ (dict(since="2026-09-10", until="2026-09-09"), True, "until before since"),
272
+ (dict(cap=0), True, "cap 0 divides by zero halfway through the report"),
273
+ (dict(cap=-5), True, "negative cap blames the metric for a bad flag"),
274
+ (dict(days=-30), True, "negative lookback makes date() NULL and empties the window"),
275
+ ]
276
+
277
+
278
+ def _run_arg_cases() -> int:
279
+ print("argument validation")
280
+ bad = 0
281
+ for kwargs, want_err, why in ARG_CASES:
282
+ args = dict(days=30, since=None, until=None, cap=DEFAULT_CAP)
283
+ args.update(kwargs)
284
+ got = validate_args(**args) is not None
285
+ ok = got == want_err
286
+ bad += not ok
287
+ print(f" {'PASS' if ok else 'FAIL'} expect={'reject' if want_err else 'accept'} "
288
+ f"got={'reject' if got else 'accept'} — {why}")
289
+ return bad
290
+
291
+
292
+ def _run_scoring_cases() -> int:
293
+ """The scorer decides whether the probe's error rate is believable, so its own
294
+ failure modes are the ones that silently launder a bad number into a good one."""
295
+ print("\nself-check scoring")
296
+ bad = 0
297
+ # A row a human said has no prescription at all, which the probe nonetheless
298
+ # calls actionable ("never run" matches the obligation pattern), is a false
299
+ # positive. Dropping X rows hides exactly the over-matching this scorer exists
300
+ # to expose.
301
+ cases = [
302
+ ([("X", "The scripts never run automatically.")], (0, 1, 0, 0),
303
+ "X row the probe calls actionable is a false positive, not a skipped row"),
304
+ ([("X", "Rotation rewrites the content after seven days.")], (0, 0, 0, 1),
305
+ "X row the probe leaves alone is a true negative"),
306
+ ([("P", "Verify the cache before shipping.")], (1, 0, 0, 0), "plain true positive"),
307
+ ([("S", "The daemon holds the connection open.")], (0, 0, 0, 1), "plain true negative"),
308
+ ]
309
+ for labels, want, why in cases:
310
+ got = score_draw([(i, lab, txt) for i, (lab, txt) in enumerate(labels)], DEFAULT_CAP)[:4]
311
+ ok = got == want
312
+ bad += not ok
313
+ print(f" {'PASS' if ok else 'FAIL'} expect(tp,fp,fn,tn)={want} got={got} — {why}")
314
+ # An unrecognised label must not be silently folded into the negative bucket:
315
+ # that can only make recall look better than it is.
316
+ rejected = score_draw([(1, "p", "Verify the cache.")], DEFAULT_CAP)
317
+ ok = rejected[4] == 1 and sum(rejected[:4]) == 0
318
+ bad += not ok
319
+ print(f" {'PASS' if ok else 'FAIL'} expect=skipped+counted got=skipped:{rejected[4]} "
320
+ f"scored:{sum(rejected[:4])} — a typo'd label is reported, never scored as S")
321
+ return bad
322
+
323
+
324
+ def _run_verdict_cases() -> int:
325
+ """main() refuses to exit 0 while printing "do not trust this"; run_self_check
326
+ used to print exactly that and exit 0 anyway. These pin the parity."""
327
+ print("\nself-check trustworthiness")
328
+ bad = 0
329
+ cases = [
330
+ (dict(scored=40, skipped=0, rewritten=0, tp=15, fp=1), False,
331
+ "a normal scored run is trustworthy"),
332
+ (dict(scored=0, skipped=0, rewritten=0, tp=0, fp=0), True,
333
+ "no scored rows at all"),
334
+ (dict(scored=1, skipped=0, rewritten=0, tp=0, fp=1), True,
335
+ "all-X file: scored>0 now that X counts as a negative, so the zero-row "
336
+ "guard misses it, and precision is 0.00 while the footer claims it is high"),
337
+ (dict(scored=1, skipped=0, rewritten=0, tp=0, fp=0), True,
338
+ "precision undefined (no positive predictions) is not the same as precision 1.0"),
339
+ (dict(scored=40, skipped=0, rewritten=1, tp=15, fp=1), True,
340
+ "a row the compressor rewrote is no longer the text the human labelled"),
341
+ (dict(scored=40, skipped=3, rewritten=0, tp=15, fp=1), True,
342
+ "labels that could not be read at all"),
343
+ ]
344
+ for kwargs, want, why in cases:
345
+ got = bool(self_check_verdict(**kwargs))
346
+ ok = got == want
347
+ bad += not ok
348
+ print(f" {'PASS' if ok else 'FAIL'} expect={'untrusted' if want else 'ok '} "
349
+ f"got={'untrusted' if got else 'ok '} — {why}")
350
+ return bad
351
+
352
+
353
+ def _run_legacy_diagnosis_cases() -> int:
354
+ """0.0% in-cap has two causes with opposite meanings, and the warning text used
355
+ to assert the one that happens to flatter the change being measured."""
356
+ print("\nlegacy locator diagnosis")
357
+ bad = 0
358
+ cases = [
359
+ (0, 0, "absent", "no markers at all — labels gone, which is what #107 predicts"),
360
+ (0, 17, "late", "17 markers present but every one past the cap — labels present, late"),
361
+ (3, 17, "some", "some markers inside the cap"),
362
+ ]
363
+ for n_in, n_has, want, why in cases:
364
+ got = legacy_diagnosis(n_in, n_has)
365
+ ok = got == want
366
+ bad += not ok
367
+ print(f" {'PASS' if ok else 'FAIL'} expect={want:<7} got={got:<7} — {why}")
368
+ return bad
369
+
370
+
371
+ def run_tests(cap: int) -> int:
372
+ print("locator unit tests (synthetic strings, no DB touched)\n")
373
+ bad = 0
374
+ # DEFAULT_CAP, not the caller's --cap: every case here is a property of the
375
+ # locator ("a verb inside a filename is not an instruction"), and none of the
376
+ # strings is long enough for the cap to be the thing under test. Honouring
377
+ # --cap here only manufactured failures — `--test --cap 40` reported two
378
+ # broken locator cases while the locator was fine.
379
+ for text, want, why in TEST_CASES:
380
+ got = delivered(probe(text), DEFAULT_CAP)
381
+ ok = got == want
382
+ bad += not ok
383
+ print(f" {'PASS' if ok else 'FAIL'} expect={'action' if want else 'none '} "
384
+ f"got={'action' if got else 'none '} — {why}")
385
+ if not ok:
386
+ print(f" text: {text}")
387
+ total = len(TEST_CASES)
388
+ bad += _run_arg_cases(); total += len(ARG_CASES)
389
+ bad += _run_scoring_cases(); total += 5
390
+ bad += _run_legacy_diagnosis_cases(); total += 3
391
+ bad += _run_verdict_cases(); total += 6
392
+ print()
393
+ if bad:
394
+ print(f"[exit 1] {bad}/{total} tests failed", file=sys.stderr)
395
+ return 1
396
+ print(f"{total}/{total} passed")
397
+ return 0
398
+
399
+
400
+ def run_self_check(path: str, cap: int) -> int:
401
+ """Score the probe against human labels. The point is not a high score; it is
402
+ that the score exists and is printed, so nobody has to assume one."""
403
+ if not os.path.exists(path):
404
+ print(f"annotation set not found: {path}\n"
405
+ f"Expected JSON: {{\"cap\": <int>, \"labels\": [{{\"id\": <memory id>, "
406
+ f"\"label\": \"P\"|\"S\"|\"X\"}}]}}\n"
407
+ f"P = a reader could act on the first N chars, S = symptom only, "
408
+ f"X = the memory has no prescription to cut.", file=sys.stderr)
409
+ return 2
410
+ # A hostile or half-written annotation file must fail with a sentence, the way
411
+ # every other bad input to this script does, not with a traceback.
412
+ try:
413
+ ann = json.loads(pathlib.Path(path).read_text(encoding="utf-8"))
414
+ entries = [(int(r["id"]), r["label"], int(r.get("draw", 1))) for r in ann["labels"]]
415
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
416
+ print(f"annotation set at {path} is not usable: {exc}", file=sys.stderr)
417
+ return 2
418
+
419
+ # The labels were written against a specific cap and the file records it.
420
+ # Scoring probe@60 against human@149 compares two different questions, and
421
+ # the old code did it silently while relabelling the humans' criteria with
422
+ # whatever --cap the caller happened to pass.
423
+ label_cap = ann.get("cap")
424
+ if label_cap is not None and label_cap != cap:
425
+ print(f"--cap {cap} does not match the cap the labels were made at ({label_cap}). "
426
+ f"Scoring the probe at one cap against humans reading another measures "
427
+ f"nothing. Re-run without --cap, or re-label.", file=sys.stderr)
428
+ return 2
429
+
430
+ db = resolve_db()
431
+ if not os.path.exists(db):
432
+ print(f"DB not found: {db}", file=sys.stderr)
433
+ return 2
434
+ con = sqlite3.connect(pathlib.Path(db).resolve().as_uri() + "?mode=ro", uri=True)
435
+ ids = [e[0] for e in entries]
436
+ qs = ",".join("?" * len(ids)) if ids else "NULL"
437
+ rows = dict(con.execute(f"SELECT id, content FROM memories WHERE id IN ({qs})", tuple(ids)))
438
+ # compression_level is not in the population predicate here the way it is in
439
+ # main(), because the labels name specific rows. But rotation rewrites content
440
+ # in place at L1, so a row can still be present and no longer be the text the
441
+ # human read. Report it rather than scoring the compressor's prose against a
442
+ # human label written about the original.
443
+ rewritten = [i for i, in con.execute(
444
+ f"SELECT id FROM memories WHERE compression_level > 0 AND id IN ({qs})", tuple(ids))] if ids else []
445
+ con.close()
446
+ print(f"DB : {db}\n標註集: {path} ({len(rows)}/{len(entries)} 筆仍在庫中)")
447
+ if rewritten:
448
+ print(f"⚠️ {len(rewritten)} 筆已被壓縮器改寫(compression_level > 0) — 這些的 content "
449
+ f"不再是人工當時讀的文字, 分數已不可比: {rewritten[:8]}")
450
+ print()
451
+
452
+ # Scores are reported per draw, never pooled into one headline. The probe's
453
+ # quote-masking and filename guards were both written while staring at draw 1,
454
+ # so draw 1 is a development set and its score is inflated by construction.
455
+ # Draw 2 was labelled after the probe was frozen: recall fell 0.75 -> 0.48 on
456
+ # it. One pooled number would have hidden that entirely.
457
+ draws = {}
458
+ for mid, label, dno in entries:
459
+ draws.setdefault(dno, []).append((mid, label))
460
+ borderline = sum(1 for r in ann["labels"] if r.get("borderline"))
461
+
462
+ # Rewritten rows are dropped, not merely announced. Scoring them compares the
463
+ # probe against compressor prose while the label describes text that no longer
464
+ # exists, and the previous version printed "分數已不可比" and then went ahead
465
+ # and computed the score anyway.
466
+ stale = set(rewritten)
467
+ misses, scored_total, skipped_total, tp_total, fp_total = [], 0, 0, 0, 0
468
+ for dno in sorted(draws):
469
+ present = [(mid, lab, rows[mid]) for mid, lab in draws[dno]
470
+ if mid in rows and mid not in stale]
471
+ tp, fp, fn, tn, skipped, ms = score_draw(present, cap)
472
+ misses += [(mid, dno, kind, head) for mid, _, kind, head in ms]
473
+ n = tp + fp + fn + tn
474
+ scored_total += n
475
+ skipped_total += skipped
476
+ tp_total += tp
477
+ fp_total += fp
478
+ if not n:
479
+ print(f"draw {dno} — 0 筆可計分(標籤不在庫中或全部無法辨識), 本 draw 無分數")
480
+ continue
481
+ prec = tp / (tp + fp) if tp + fp else float("nan")
482
+ rec = tp / (tp + fn) if tp + fn else float("nan")
483
+ role = "開發集(探針照著它調過, 分數偏高)" if dno == 1 else "holdout(探針凍結後才標, 這個才是實力)"
484
+ print(f"draw {dno} — {role}")
485
+ print(f" n={n} precision {prec:.2f} recall {rec:.2f} accuracy {(tp+tn)/n:.2f}"
486
+ f" [TP {tp} FP {fp} FN {fn} TN {tn}]"
487
+ + (f" ⚠️ {skipped} 筆標籤無法辨識, 未計分" if skipped else ""))
488
+
489
+ if misses:
490
+ # These are excerpts of the user's own stored memories. main() prints only
491
+ # the dedup key for the same reason — this project has pasted dogfood
492
+ # output into public issues before.
493
+ print("\n分歧樣本 — 這些是這支探針量不準的形狀, 讀它們比讀分數有用:")
494
+ print("⚠️ 以下含記憶原文片段, 貼進公開 issue / PR 前先去識別化。")
495
+ for mid, dno, kind, head in misses[:MAX_MISSES_PRINTED]:
496
+ print(f" [{mid}] draw{dno} {kind}\n {head}…")
497
+ if len(misses) > MAX_MISSES_PRINTED:
498
+ print(f" … 另有 {len(misses) - MAX_MISSES_PRINTED} 筆未列出")
499
+
500
+ untrusted = self_check_verdict(scored_total, skipped_total, len(stale),
501
+ tp_total, fp_total)
502
+ print(f"\n⚠️ 這個分數量的是「探針 vs 人工判讀」, 不是「探針 vs 真相」。人工判讀"
503
+ f"\n 自己有邊界案例({borderline}/{len(entries)} 筆), 分數的天花板就在那裡。")
504
+ # Gated, never asserted: the previous version printed "precision 遠高於
505
+ # recall" unconditionally, including over a population where precision was
506
+ # 0.00. A claim about measured numbers only gets printed when this run's
507
+ # numbers actually support it, which is what self_check_verdict decides.
508
+ if untrusted:
509
+ print(f"\n[exit 1] 本次 self-check 不可作為判定依據: {' / '.join(untrusted)}",
510
+ file=sys.stderr)
511
+ return 1
512
+ print("📌 這支探針的已知形狀(來自上面的實測, 不是預設): precision 高於 recall — 它說"
513
+ "\n 有動作時通常對, 但漏掉整類「判斷型處方」(『這條路不可靠』『這個判準在大專案"
514
+ "\n 零效用』——無祈使動詞、無義務情態)。所以主指標偏低而非偏高。")
515
+ return 0
516
+
517
+
518
+ def validate_args(days: int, since: Optional[str], until: Optional[str],
519
+ cap: int) -> Optional[str]:
520
+ """Reject every window/cap value that would mislead rather than fail.
521
+
522
+ Each rule here is a defect that shipped: the flags failed silently, and in
523
+ opposite directions, so the reader saw a plausible number computed over the
524
+ wrong rows.
525
+ """
526
+ for name, value in (("--since", since), ("--until", until)):
527
+ if value is None:
528
+ continue
529
+ # [0-9], not \d: Python's \d is Unicode-aware, so U+FF10-FF19 pass the
530
+ # shape check. SQLite then compares BINARY, where a fullwidth digit sorts
531
+ # above every ASCII date — `date(created_at) < '2026-…'` is true for
532
+ # every row, and the flag whose whole job is to separate the control arm
533
+ # from the treatment arm becomes a no-op that still exits 0.
534
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
535
+ return (f"{name} must be YYYY-MM-DD in ASCII digits, got {value!r} — SQLite "
536
+ f"compares created_at lexically, so a malformed value silently "
537
+ f"measures the wrong population")
538
+ # Shape is not a date: 2026-13-45 and 2026-02-30 both match the pattern.
539
+ try:
540
+ datetime.date.fromisoformat(value)
541
+ except ValueError:
542
+ return f"{name}={value!r} is not a real calendar date"
543
+ if since and until and until <= since:
544
+ return (f"--until {until} is not after --since {since}: --until is exclusive, "
545
+ f"so this window is empty")
546
+ if days <= 0:
547
+ return (f"--days must be positive, got {days} — a negative modifier makes "
548
+ f"date() return NULL and every row falls outside the window")
549
+ if cap <= 0:
550
+ return (f"--cap must be positive, got {cap} — cap 0 divides by zero midway "
551
+ f"through the report, and a negative cap reports 'counter-probe "
552
+ f"failed', blaming the metric for a bad flag")
553
+ return None
554
+
555
+
556
+ def legacy_diagnosis(n_in_cap: int, n_located: int) -> str:
557
+ """Why is the legacy in-cap rate 0%? Two causes, opposite meanings.
558
+
559
+ 'absent' — no markers anywhere: labels are gone, which is what leading with
560
+ the prescription predicts, and the reading the old warning gave.
561
+ 'late' — markers present, all past the cap: labels are NOT gone, they are
562
+ late. The old text asserted 'absent' here too, which contradicted
563
+ the '有明確處方段: 17/90' line printed four lines above it.
564
+ """
565
+ if not n_located:
566
+ return "absent"
567
+ return "late" if n_in_cap == 0 else "some"
568
+
569
+
570
+ def self_check_verdict(scored: int, skipped: int, rewritten: int,
571
+ tp: int, fp: int) -> list:
572
+ """Reasons this self-check cannot be used as evidence. Empty means it can.
573
+
574
+ main() has had this shape since the rewrite: print the numbers, then refuse
575
+ to exit 0 while also printing "do not trust this output". run_self_check
576
+ printed the disclaimer and exited 0 regardless — and it is the escape hatch
577
+ the main path tells operators to run when the probe looks broken, so it is
578
+ the one place that must not be able to certify itself.
579
+ """
580
+ reasons = []
581
+ if not scored:
582
+ reasons.append("0 筆可計分")
583
+ # An all-X annotation file stopped being a zero-row case the moment X rows
584
+ # became negatives: it scores, reports precision 0.00, and used to reach the
585
+ # footer that asserts precision is far above recall.
586
+ elif tp + fp == 0:
587
+ reasons.append("探針一次都沒說「有動作」⇒ precision 無定義, 不是 1.0")
588
+ elif tp == 0:
589
+ reasons.append(f"precision 0.00 (FP {fp}, TP 0) ⇒ 沒有任何一次判對")
590
+ if rewritten:
591
+ reasons.append(f"{rewritten} 筆已被壓縮器改寫 ⇒ 評的不是人工讀過的文字")
592
+ if skipped:
593
+ reasons.append(f"{skipped} 筆標籤無法辨識")
594
+ return reasons
595
+
596
+
597
+ def score_draw(rows, cap: int):
598
+ """Score one draw. Returns (tp, fp, fn, tn, skipped, misses).
599
+
600
+ X rows — a human saying "this memory has no prescription to cut" — are scored
601
+ as negatives, not skipped. A probe that calls such a row actionable is
602
+ over-matching, and over-matching is the failure this scorer exists to expose;
603
+ dropping the rows meant the one class most likely to produce a false positive
604
+ could never appear in the denominator.
605
+ """
606
+ tp = fp = fn = tn = skipped = 0
607
+ misses = []
608
+ for mid, human, content in rows:
609
+ if human not in ("P", "S", "X"):
610
+ # Never fold an unrecognised label into the negative bucket: a typo'd
611
+ # "p" would move a true positive the probe missed out of fn and into
612
+ # tn, which can only make recall look better than it is.
613
+ skipped += 1
614
+ misses.append((mid, None, f"SKIP unrecognised label {human!r}", content[:92]))
615
+ continue
616
+ machine = delivered(probe(content), cap)
617
+ if human == "P" and machine: tp += 1
618
+ elif human == "P": fn += 1; misses.append((mid, None, "MISS human=P machine=none", content[:92]))
619
+ elif machine: fp += 1; misses.append((mid, None, f"FALSE human={human} machine=action", content[:92]))
620
+ else: tn += 1
621
+ return tp, fp, fn, tn, skipped, misses
622
+
623
+
624
+ def resolve_db() -> str:
625
+ return os.environ.get("CCRECALL_DB_PATH") or os.path.expanduser("~/.ccrecall/ccrecall.db")
626
+
627
+
628
+ def main() -> int:
629
+ ap = argparse.ArgumentParser()
630
+ ap.add_argument("--days", type=int, default=30, help="lookback window (default 30)")
631
+ ap.add_argument("--since", help="absolute start date YYYY-MM-DD; overrides --days. "
632
+ "Use this to measure only memories written after a "
633
+ "prompt change, so pre- and post- samples never mix.")
634
+ ap.add_argument("--until", help="exclusive end date YYYY-MM-DD. Without it a "
635
+ "'before the change' run silently includes every "
636
+ "row written after it, i.e. the arm it is the control for.")
637
+ ap.add_argument("--cap", type=int, default=DEFAULT_CAP,
638
+ help=f"per-row char cap surviving truncation (default {DEFAULT_CAP})")
639
+ ap.add_argument("--test", action="store_true", help="run locator unit tests and exit (no DB)")
640
+ ap.add_argument("--self-check", nargs="?", const=DEFAULT_ANNOTATIONS, metavar="PATH",
641
+ help=f"score the probe against human labels (default {DEFAULT_ANNOTATIONS})")
642
+ args = ap.parse_args()
643
+
644
+ if args.test:
645
+ return run_tests(args.cap)
646
+ if args.self_check:
647
+ return run_self_check(args.self_check, args.cap)
648
+
649
+ err = validate_args(args.days, args.since, args.until, args.cap)
650
+ if err:
651
+ print(err, file=sys.stderr)
652
+ return 2
653
+
654
+ db = resolve_db()
655
+ if not os.path.exists(db):
656
+ print(f"DB not found: {db}", file=sys.stderr)
657
+ return 2
658
+ print(f"DB : {db}")
659
+ # as_uri() percent-escapes the path. Interpolating it raw let a `?` or `#` in
660
+ # the path truncate the URI, discarding `mode=ro` — which opens read-write and
661
+ # CREATES the file, in an application whose first rule is that it never writes.
662
+ #
663
+ # `mode=ro` is not a total write guarantee for a WAL database: per SQLite's WAL
664
+ # documentation a read-only connection may create the -shm/-wal sidecars when
665
+ # they do not already exist and the directory is writable. In practice the
666
+ # daemon holds a connection open and they always do. `immutable=1` would close
667
+ # the gap and is deliberately NOT used: it asserts the file never changes,
668
+ # which is false for a live database and would trade a theoretical write for
669
+ # silently stale reads — the worse failure for a measurement tool.
670
+ con = sqlite3.connect(pathlib.Path(db).resolve().as_uri() + "?mode=ro", uri=True)
671
+ # The population has to match what the injection path can actually select, or
672
+ # the headline describes memories no session will ever be shown:
673
+ # - type/confidence mirror the Tier 0 filters inside getStartupMemories in
674
+ # src/core/database.ts (grep the predicate; line numbers in a 2600-line
675
+ # file rot faster than the code does).
676
+ # - compression_level = 0 is the load-bearing one. Rotation permanently
677
+ # rewrites content, stripping the markers: measured 2026-09-08 over 30
678
+ # days, cl=0 rows carry a marker 26.8% of the time (median length 687)
679
+ # against 0.3% for compressed ones (median 153). Including them measures
680
+ # the compressor's output as if the extraction prompt had written it, and
681
+ # drags the locatable share toward this script's own <10% tripwire — a
682
+ # share that then falls purely as the corpus ages, firing the "metric may
683
+ # be destroyed" warning for a reason unrelated to any prompt change.
684
+ where = "compression_level = 0 AND type != 'query' AND confidence >= 0.8"
685
+ params: tuple = ()
686
+ if args.until:
687
+ where += " AND date(created_at) < ?"
688
+ params = (args.until,)
689
+ cols = "SELECT id, date(created_at), COALESCE(key,''), content FROM memories WHERE "
690
+ if args.since:
691
+ print(f"窗口: {args.since} 起{' 到 ' + args.until + ' 前' if args.until else ''}"
692
+ f" | 截斷線: {args.cap} 字元")
693
+ rows = con.execute(cols + where + " AND date(created_at) >= ?",
694
+ params + (args.since,)).fetchall()
695
+ else:
696
+ print(f"窗口: 近 {args.days} 天{' 到 ' + args.until + ' 前' if args.until else ''}"
697
+ f" | 截斷線: {args.cap} 字元")
698
+ rows = con.execute(cols + where + " AND date(created_at) >= date('now', ?)",
699
+ params + (f"-{args.days} days",)).fetchall()
700
+ con.close()
701
+ print("母體限定: 未壓縮(compression_level=0) + Tier 0 可選(type/confidence) — "
702
+ "壓縮過的 content 是壓縮器寫的, 不是 extraction prompt 寫的")
703
+ print()
704
+
705
+ if not rows:
706
+ print("PIPELINE EMPTY: 0 rows in window — widen --days or check the DB, "
707
+ "do not read this as '0% of prescriptions are cut'", file=sys.stderr)
708
+ return 1
709
+
710
+ # Print the realised range, not just the requested one: it is the only thing
711
+ # that shows a window flag did something other than what its label says.
712
+ dates = sorted(r[1] for r in rows if r[1])
713
+ span = f"{dates[0]} .. {dates[-1]}" if dates else "無日期"
714
+ print(f"母體: {len(rows)} 筆 | 實際日期範圍 {span} | "
715
+ f"平均全長 {statistics.mean(len(r[3]) for r in rows):.0f} 字元")
716
+ print()
717
+
718
+ # ── PRIMARY: actionability ─────────────────────────────────────────────────
719
+ probed = [(r, probe(r[3])) for r in rows]
720
+ strong_in = [(r, sig.strong_start) for r, sig in probed if delivered(sig, args.cap)]
721
+ strong_any = [(r, sig.strong_start) for r, sig in probed if sig.strong_start is not None]
722
+ # The upper bound uses weak_END for the same reason the lower bound uses
723
+ # strong_end: a hedge word cut in half is not delivered either.
724
+ weak_only_in = [r for r, sig in probed
725
+ if not delivered(sig, args.cap)
726
+ and sig.weak_end is not None and sig.weak_end <= args.cap]
727
+ lo = len(strong_in)
728
+ hi = lo + len(weak_only_in)
729
+ print("=== 主指標: 可操作性(前 %d 字元內有沒有可執行動作) ===" % args.cap)
730
+ print(f">>> 強訊號率(明確祈使/義務, 且不在引號內): {lo:4d}/{len(rows)} = {100*lo/len(rows):.1f}%")
731
+ print(f" 強或弱訊號率(加計語意含糊的 never/cannot/對比句): {hi:4d}/{len(rows)} = {100*hi/len(rows):.1f}%")
732
+ # Deliberately NOT called a lower and upper bound. Bounds would require the
733
+ # errors to run one way, and they run both:
734
+ # · ACTION_VERBS is a 17-verb list harvested from this corpus, so ordinary
735
+ # imperatives outside it ("Restart the daemon…", "Delete the stale
736
+ # lockfile…") land in neither figure — the human-labelled holdout puts
737
+ # recall at 0.48, i.e. about half the real instructions are missed.
738
+ # · `never <verb>` inflates the strong figure on descriptive sentences;
739
+ # that false positive is test case "same trap, third-person verb after
740
+ # never" and it is a known, not a hypothetical, over-count.
741
+ # So the gap between the two is a signal-strength split, not a resolution
742
+ # limit, and neither figure brackets the truth on its own.
743
+ print(f" 兩者相差 {hi-lo} 筆 — 這是訊號強度的分界, **不是**誤差界: 探針兩側都會錯"
744
+ f"(holdout recall 0.48 漏掉一半, `never <verb>` 則高估), 見 --self-check")
745
+ if strong_any:
746
+ pos = sorted(s for _, s in strong_any)
747
+ print(f" 動作起始位置(全母體有動作者 {len(strong_any)} 筆): 中位 {statistics.median(pos):.0f} / "
748
+ f"最小 {pos[0]} / 最大 {pos[-1]} => 中位 = 截斷線的 {statistics.median(pos)/args.cap:.1f}x")
749
+ print()
750
+
751
+ # ── LEGACY: marker locator, kept for cross-check ───────────────────────────
752
+ has = [(r, m) for r, m in ((r, RX_MARKER.search(r[3])) for r in rows) if m]
753
+ # One expression for one concept: this used to be computed here and again at
754
+ # the side-by-side print, so a change to the comparison below would have
755
+ # fixed one copy and left the two printed lines contradicting each other.
756
+ m_in = [(r, m) for r, m in has if m.start(1) < args.cap]
757
+ print("=== 對照口徑(legacy): 明確處方標記 ===")
758
+ print(f" 有明確處方段: {len(has):4d}/{len(rows)} = {100*len(has)/len(rows):.1f}%")
759
+ if has:
760
+ # m.start() is the offset of the consumed separator, not of the marker; only
761
+ # group 1 is the marker itself. The delta ran 2-3 chars over the live corpus,
762
+ # always UNDER-reporting cuts — harmless at a median offset of 451, but this
763
+ # is the acceptance instrument for a change whose whole purpose is to move
764
+ # prescriptions to just inside the cap, i.e. into exactly the band where a
765
+ # 2-char bias flips the verdict, and it flips it toward "delivered".
766
+ print(f" 其中落在線內: {len(m_in)}/{len(has)} = {100*len(m_in)/len(has):.1f}%")
767
+ mpos = sorted(m.start(1) for _, m in has)
768
+ print(f" 標記位置中位 {statistics.median(mpos):.0f} = 截斷線的 "
769
+ f"{statistics.median(mpos)/args.cap:.1f}x")
770
+ else:
771
+ print(" (0 筆 — 見檔頭: 標籤消失是 #107 的預期後果, 不是處方消失)")
772
+ print()
773
+
774
+ # ── The disagreement between the two locators IS the finding ───────────────
775
+ marker_rate = (100 * len(m_in) / len(has)) if has else 0.0
776
+ diagnosis = legacy_diagnosis(len(m_in), len(has))
777
+ print("=== 兩個口徑並列 ===")
778
+ print(f" 可操作性(主): {100*lo/len(rows):.1f}% – {100*hi/len(rows):.1f}% | "
779
+ f"標記在線內(legacy): {marker_rate:.1f}%")
780
+ if lo > 0 and diagnosis == "absent":
781
+ print(" 🔴 legacy 找不到任何標記, 而主口徑非零 — 2026-09-12 人工盲審確認的失效模式:")
782
+ print(" 處方寫進首句就不再需要 `Mitigation:` 標籤, 標籤消失被 legacy 讀成處方消失。")
783
+ print(" **不要引用 legacy 數字下結論**, 它在這個文體下與事實反向。")
784
+ elif lo > 0 and diagnosis == "late":
785
+ mpos = statistics.median(sorted(m.start(1) for _, m in has))
786
+ print(f" 🔴 legacy 歸零的原因不是標記消失 — {len(has)} 筆帶標記, 中位落在 {mpos:.0f} 字元,")
787
+ print(" 全部在截斷線之後。legacy 量的是**標記位置**不是可操作性, 兩者在這批語料上")
788
+ print(" 已經脫鉤。**不要引用 legacy 數字下結論**。")
789
+ print()
790
+
791
+ untrusted = []
792
+ # Counter-probe: a metric that cannot say "fine" for anything is not measuring.
793
+ verdict = "YES, 主指標有辨別力" if strong_in else "NO — 主指標恆為 FAIL, 結果不可信"
794
+ print(f"[counter-probe] 存在動作落在線內的樣本嗎? {lo} 筆 -> {verdict}")
795
+ if strong_in:
796
+ r, s = min(strong_in, key=lambda x: x[1])
797
+ print(f" 最早動作位置 {s} 字元: {r[2] or r[0]}")
798
+ else:
799
+ untrusted.append("counter-probe 失敗(無線內樣本, 主指標恆為 FAIL)")
800
+ print()
801
+
802
+ if strong_any:
803
+ # The complement of strong_in, not a third rule: under `s >= cap` a row
804
+ # whose verb straddles the cut (start 145, end 151) appeared in neither
805
+ # list and silently left the report.
806
+ _in = {id(r) for r, _ in strong_in}
807
+ cut = [(r, s) for r, s in strong_any if id(r) not in _in]
808
+ if cut:
809
+ print("--- 有動作但落在截斷線後最遠的 8 筆 ---")
810
+ for r, s in sorted(cut, key=lambda x: -x[1])[:8]:
811
+ print(f" @{s:4d} (全長{len(r[3]):4d}) {r[2] or r[0]}")
812
+ print()
813
+
814
+ # A locator that finds an action in nearly every row is not discriminating
815
+ # either; the marker locator's old failure was the mirror image of this one.
816
+ if len(strong_any) / len(rows) > 0.95:
817
+ print("⚠️ >95% 的樣本都被判定為「有動作」— 這支探針可能只是在匹配常用動詞。")
818
+ print(" 跑 --self-check 對照人工標籤, 別直接引用上面的百分比。")
819
+ untrusted.append("動作偵測率 >95%(可能失去鑑別力)")
820
+ if len(strong_any) / len(rows) < 0.10:
821
+ print("⚠️ <10% 的樣本被判定為「有動作」— 探針可能對這批語料失效(換了語言或文體)。")
822
+ print(" 重跑動詞頻率掃描(見 ACTION_VERBS 註解), 不要用本數字下結論。")
823
+ untrusted.append("動作偵測率 <10%(探針可能對此語料失效)")
824
+
825
+ # Exit code tracks trustworthiness, not "did python finish". Every state that
826
+ # prints "do not trust this output" used to return 0, so `script && publish`
827
+ # published precisely the runs the script had disowned.
828
+ if untrusted:
829
+ print(f"[exit 1] 本次輸出不可作為判定依據: {' / '.join(untrusted)}", file=sys.stderr)
830
+ return 1
831
+ return 0
832
+
833
+
834
+ if __name__ == "__main__":
835
+ sys.exit(main())