@tznthou/ccrecall 0.7.1 → 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.
- package/README.md +31 -16
- package/README_ZH.md +29 -16
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +25 -10
- package/dist/api/routes.js.map +1 -1
- package/dist/core/database.d.ts +62 -1
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +189 -26
- package/dist/core/database.js.map +1 -1
- package/dist/core/integrity-monitor.d.ts.map +1 -1
- package/dist/core/integrity-monitor.js +7 -3
- package/dist/core/integrity-monitor.js.map +1 -1
- package/dist/core/recall-telemetry.d.ts.map +1 -1
- package/dist/core/recall-telemetry.js +9 -3
- package/dist/core/recall-telemetry.js.map +1 -1
- package/dist/core/secure-dir.d.ts +7 -0
- package/dist/core/secure-dir.d.ts.map +1 -0
- package/dist/core/secure-dir.js +64 -0
- package/dist/core/secure-dir.js.map +1 -0
- package/dist/core/token-budget.d.ts +59 -3
- package/dist/core/token-budget.d.ts.map +1 -1
- package/dist/core/token-budget.js +115 -11
- package/dist/core/token-budget.js.map +1 -1
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +70 -12
- package/dist/mcp/tools.js.map +1 -1
- package/hooks/lib/secure-dir.mjs +52 -0
- package/hooks/session-start.mjs +20 -3
- package/package.json +3 -2
- package/scripts/extraction-prompt.md +52 -1
- package/scripts/l3-prescription-position.py +835 -0
- package/scripts/post-session-extract.sh +43 -7
|
@@ -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())
|