Skip to content

Commit f606c01

Browse files
CamSoperclaude
andauthored
Make the nightly claims re-verify check facts, not itself (#20851)
* Make the nightly claims re-verify check facts, not itself The lane's first two live sweeps (Aug 11-12) checked 50 volatile entities and found 0 stale. Both halves of that result were misleading, in the two ways the workflow header had flagged as worth watching. Verdicts sourced from our own docs: 22 of the 35 `verified` verdicts cited nothing but Pulumi's own published content — 16 of them the published copy of the very page making the claim ("verified high" for content/docs/iac/get-started/aws/modify-program.md, source https://www.pulumi.com/docs/iac/get-started/aws/modify-program/). www.pulumi.com is this repo rendered, so those checks confirmed each page against itself: they can never go stale, and a `contradicted` from the same place would be equally meaningless. The worst case restated a marketing figure ("5-10X improvements") as verified by quoting itself. `source_is_own_corpus` now demotes any decided verdict whose every cited source is a www.pulumi.com URL or a `content/` file to `unverifiable` — reported, never counted fresh, never written as a stale marker. Positive evidence only: a source naming nothing identifiable (no URL, no path) is left alone, since unrecognized is not the same as circular. Replayed over both nights' reports this demotes 22 of 35, leaving 13 real verifications against AWS docs, pulumi/pulumi source, and the policy-pack data files. Entities that were never volatile: 48 of the 50 were typed `numerical`, which entity_key.py made volatile unconditionally, so counts of a page's own example output rode along — "this tutorial takes 15 minutes", "the example update creates 3 resources". Those figures are properties of the prose and can only change when the page changes; 8 came back `not-a-claim` from the verifier saying so. They are now excluded from `volatile` (still keyed, still indexed, still checked whenever the page is reviewed). reverify-claims.py re-derives the flag through `entity_key.is_volatile` instead of trusting what each snapshot stored, so the narrower rule applies to the whole index the night it ships rather than page by page as each page is re-reviewed. It falls back to the stored flag if the module can't load. Also adds the report artifact the Slack summary has always pointed at but the workflow never uploaded. It matters more than it sounds: Actions masks every occurrence of the org name, so source URLs in the run log read `www.***.com`, which is exactly the field this change turns on. The two fixes are complementary and deliberately partial. Narrowing the classifier shrinks the set; the source rule stops the remainder from being recorded as confirmed on circular evidence. Neither prevents the verifier from reaching for the site in the first place — doing that means touching verify-claims.py's prompt, which the pre-merge review lane shares, so it wants its own change. `make lint`, `make test-review-pipeline` (21 suites) green. The new assertions in both suites were negative-tested: they go red against the pre-fix code and name the specific behavior that regressed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Address both low-confidence review findings Health-lane interaction with demotion (⚠️ L367-370): Demoted verdicts land in `n_inconclusive`, the counter signal-health.py treats as evidence the lane is broken — so a night where every verdict was demoted would, after 7 days, Slack #docs-ops advice to check the S3 sync, the API key, and the verifier's gh lane, none of which would be the cause. Kept the alarm rather than suppressing it: an all-demoted night detected no drift, which is the thing this signal exists to notice. What was wrong is the diagnosis, not the alert, so `observe_reverify` now carries the demotion count into the detail and the consequence text reads the two apart — a demotion count means the verifier's source routing, everything else means plumbing. This is why the review's suggested `n_inconclusive > n_demoted` guard isn't what landed: it returns "ok" for a lane doing no useful work. Also corrected the two places that described all-inconclusive as the broken-verifier tell (signal-health.py's signal index, reverify-claims.py's meta-block comment); demotion reaches the same counter by a different route. Pricing/limit veto on the self-describing exclusion (⚠️ L168-169): The exclusion set `volatile = False` unconditionally, so it beat a price/limit match instead of yielding to it: "the example stack costs about $12 per month" matched on `example` and left the nightly lane, even though the figure drifts without the page changing. PRICING_LIMIT_RE now vetoes the exclusion. The asymmetry is the argument — a stray example count costs one verifier call and a demoted verdict, a dropped price is never checked again. Both suites negative-tested again: the veto cases and the demotion detail go red against the pre-fix code. `make lint` and `make test-review-pipeline` (21 suites) green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d7fe8b9 commit f606c01

4 files changed

Lines changed: 247 additions & 21 deletions

File tree

.claude/commands/docs-review/scripts/entity_key.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
`volatile` marks the claim types worth cheap nightly re-verification straight
3131
from the index — assertions whose truth drifts with the outside world even
3232
when the page doesn't change: version pins, prices/rates/limits, and
33-
pricing/limit-flavored entity specs.
33+
pricing/limit-flavored entity specs. A `numerical` claim about the page's own
34+
example or tutorial output is excluded: see SELF_DESCRIBING_RE.
3435
3536
No LLM involvement: derivation is pure text normalization, so keys are
3637
reproducible from the claim record alone. Run the smoke checks with
@@ -61,6 +62,30 @@
6162
re.IGNORECASE,
6263
)
6364

65+
# A `numerical` claim counting something the page itself produced — "the
66+
# example update creates 3 resources", "this tutorial takes 15 minutes", "the
67+
# preview output shows 2 changes". The figure is a property of the prose, not
68+
# of the world: it can only change when the page changes, so the nightly lane
69+
# can never find drift in it, and the verifier has nothing to check it against
70+
# except the page it came from (which is what makes those checks circular —
71+
# see reverify-claims.py's own-corpus demotion). Excluded from `volatile`;
72+
# still keyed, still indexed, still re-checked whenever the page is reviewed.
73+
#
74+
# PRICING_LIMIT_RE vetoes the exclusion, because the two overlap on the one
75+
# phrasing where this rule would otherwise do damage: "the example stack costs
76+
# $12/month" is self-describing in form but the figure is an outside-world
77+
# price that drifts on its own. Keeping a stray example count in the nightly
78+
# pool costs one verifier call and a demoted verdict; dropping a real price
79+
# means never checking it again.
80+
SELF_DESCRIBING_RE = re.compile(
81+
r"\b(?:examples?|samples?|tutorials?|walkthroughs?|screenshots?|"
82+
r"meta[- ]description|"
83+
r"(?:preview|update|command|terminal|console)\s+output|"
84+
r"output\s+(?:shows|displays|lists|reports|contains|includes)|"
85+
r"shown\s+(?:above|below))\b",
86+
re.IGNORECASE,
87+
)
88+
6489
MAX_SUBJECT_TOKENS = 5
6590

6691
_STOPWORDS = {
@@ -147,6 +172,9 @@ def derive(claim: dict) -> tuple[str | None, bool]:
147172
volatile = ctype in ALWAYS_VOLATILE_TYPES or (
148173
ctype == "entity-spec" and bool(PRICING_LIMIT_RE.search(text))
149174
)
175+
if (ctype == "numerical" and SELF_DESCRIBING_RE.search(text)
176+
and not PRICING_LIMIT_RE.search(text)):
177+
volatile = False
150178

151179
if ctype not in KEYED_TYPES:
152180
return None, volatile
@@ -170,6 +198,17 @@ def derive(claim: dict) -> tuple[str | None, bool]:
170198
return f"{ctype}/{'-'.join(tokens)}", volatile
171199

172200

201+
def is_volatile(claim: dict) -> bool:
202+
"""Volatility of one claim record, re-derived from `type`/`text`.
203+
204+
Both inputs are persisted in the claims index, so `reverify-claims.py`
205+
calls this to apply the current policy to snapshots that were stamped
206+
under an older one — a narrowed rule takes effect on the whole index that
207+
night rather than page by page as each is re-reviewed.
208+
"""
209+
return derive(claim)[1]
210+
211+
173212
def stamp(claim: dict) -> dict:
174213
"""Mutate `claim` in place with `entity_key` and `volatile`; returns it."""
175214
key, volatile = derive(claim)
@@ -234,6 +273,40 @@ def check(name: str, cond: bool) -> None:
234273
"text": "Pulumi-hosted deployment runners run in AWS us-west-2."})
235274
check("hosting entity-spec is not volatile", v is False)
236275

276+
# Self-describing numerical claims: still keyed, never volatile. Each of
277+
# these was in the nightly lane's first two live sweeps.
278+
for text in ("This tutorial takes about 15 minutes to complete.",
279+
"The example update creates 3 resources.",
280+
"The preview output shows 2 changes.",
281+
"The page meta description characterizes the list as complete.",
282+
"The command output lists 4 stacks."):
283+
k, v = derive({"type": "numerical", "text": text})
284+
check(f"self-describing not volatile: {text[:34]}...", v is False)
285+
check(f"self-describing still keyed: {text[:34]}...", k is not None)
286+
287+
# ...but a count of something the outside world owns still is, including
288+
# the policy-pack row counts that share the "lists/complete" vocabulary.
289+
k, v = derive({"type": "numerical",
290+
"text": "This page lists all 139 policies in the NIST SP 800-53 pack."})
291+
check("policy-pack count stays volatile", v is True)
292+
k, v = derive({"type": "numerical",
293+
"text": "An operation is retried a maximum of 100 times."})
294+
check("external limit stays volatile", v is True)
295+
296+
# A price or limit inside self-describing phrasing keeps the claim in the
297+
# nightly pool: the two regexes disagree here, and the figure still drifts
298+
# on its own even though the sentence is about the page's own example.
299+
for text in ("The example stack costs about $12 per month.",
300+
"The tutorial's cluster stays within the 5-node free limit.",
301+
"The example uses 2 of the 10 seats on the Team plan."):
302+
k, v = derive({"type": "numerical", "text": text})
303+
check(f"pricing vetoes the exclusion: {text[:34]}...", v is True)
304+
305+
# is_volatile() re-derives from a persisted record, ignoring its stored flag.
306+
check("is_volatile re-derives from type/text",
307+
is_volatile({"type": "numerical", "volatile": True,
308+
"text": "The example update creates 3 resources."}) is False)
309+
237310
# Non-keyed types: no key, not volatile.
238311
k, v = derive({"type": "behavior",
239312
"text": "`pulumi up` deploys all resources in the stack."})

.github/workflows/claims-reverify.yml

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,20 @@ name: "Scheduled jobs: Re-verify volatile claims"
2929
# TURNED ON 2026-08-11 with `CLAIMS_REVERIFY_COUNT` set explicitly to '25' in
3030
# pulumi/docs, so the first live run is a recorded decision rather than an
3131
# implicit default. At the time there were 63 volatile entities across 86
32-
# claim snapshots — a full sweep every 3 nights. Because the lane had never
33-
# executed, watch the first few reports for false-positive churn: most entities
34-
# are typed `numerical`, and some keys read like prose fragments rather than
35-
# version pins or limits, which would point at the volatile classifier rather
36-
# than at genuinely rotted facts.
32+
# claim snapshots — a full sweep every 3 nights.
33+
#
34+
# The first two live sweeps (Aug 11-12, 50 entities, 0 stale) confirmed both
35+
# of the things that warning called for; pulumi/docs#20851 fixed them:
36+
# * 48 of 50 entities were typed `numerical`, and many were counts of the
37+
# page's own example output ("the example update creates 3 resources")
38+
# rather than version pins or limits. entity_key.py now excludes
39+
# self-describing numerical claims from `volatile`, and this lane
40+
# re-derives the flag rather than trusting what the snapshot stored.
41+
# * 22 of 35 `verified` verdicts cited only Pulumi's own published docs —
42+
# 16 of them the published copy of the very page making the claim. Those
43+
# are demoted to `unverifiable` now: the site is this repo rendered, so
44+
# such a check can never detect drift. Expect `fresh` to read far lower
45+
# than it did on those two nights; the difference was never real.
3746
#
3847
# DEGRADATION HEALTH: this lane only Slacks when it finds STALE entities, so a
3948
# dead lane (claims index gone, API key missing, every check inconclusive)
@@ -179,6 +188,18 @@ jobs:
179188
echo "--- report ---"
180189
cat .claims-reverify-report.json
181190
191+
# The report is the lane's only durable evidence: the run log masks every
192+
# occurrence of the org name (so source URLs read as `www.***.com`) and
193+
# ages out with the run. Uploaded unconditionally — a run worth debugging
194+
# is usually one where the verify step failed.
195+
- name: Upload re-verify report
196+
if: always() && hashFiles('.claims-reverify-report.json') != ''
197+
uses: actions/upload-artifact@v7
198+
with:
199+
name: claims-reverify-report
200+
path: .claims-reverify-report.json
201+
retention-days: 90
202+
182203
# Counts only + a run link — the evidence lives in the report artifact
183204
# and the ledger markers; the fix arrives as a normal content-review PR.
184205
- name: Post stale summary to Slack

scripts/content-review/reverify-claims.py

Lines changed: 116 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
anything else (`unverifiable`, errors) → inconclusive, reported but never
3535
marked — a flaky check must not burn review-queue slots.
3636
37+
Evidence independence: a verdict whose only cited source is Pulumi's own
38+
published docs — a www.pulumi.com URL, or a `content/` source file — is
39+
demoted to `unverifiable` before that mapping (see `source_is_own_corpus`).
40+
The site is this repo rendered, so such a check has confirmed the page
41+
against itself and cannot detect drift in either direction.
42+
3743
Writes `.claims-reverify-report.json` (plus `n_checked`/`n_stale`/`has_stale`
3844
to $GITHUB_OUTPUT) and, when CONTENT_REVIEW_LEDGER_URI is set, uploads each
3945
marked ledger object. Degrades gracefully: no API key, no claims dir, or no
@@ -53,19 +59,24 @@
5359
import importlib.util
5460
import json
5561
import os
62+
import re
5663
import sys
5764
from concurrent.futures import ThreadPoolExecutor
5865
from datetime import date, datetime, timezone
5966
from pathlib import Path
6067

6168
HERE = Path(__file__).resolve().parents[2]
6269
VERIFY_CLAIMS = HERE / ".claude/commands/docs-review/scripts/verify-claims.py"
70+
ENTITY_KEY = HERE / ".claude/commands/docs-review/scripts/entity_key.py"
6371

6472
SCHEMA_VERSION = 1
6573
DEFAULT_COUNT = 25
6674
MAX_CONCURRENCY = 8
6775
STALE_VERDICTS = {"contradicted", "mismatch", "framing-drift"}
6876
FRESH_VERDICTS = {"verified", "matches"}
77+
# Verdicts that assert something either way, and so have to rest on evidence
78+
# from outside the docs corpus to mean anything.
79+
DECIDED_VERDICTS = STALE_VERDICTS | FRESH_VERDICTS
6980

7081

7182
def log(msg: str) -> None:
@@ -86,6 +97,55 @@ def _load_verify_claims():
8697
return mod
8798

8899

100+
def _load_is_volatile():
101+
"""entity_key.is_volatile, or None if the module can't be loaded.
102+
103+
Re-deriving beats trusting the snapshot's stored `volatile` flag: the flag
104+
was stamped whenever the page was last reviewed, so a narrowing of the
105+
policy would otherwise only reach the index page by page over months. None
106+
falls the caller back to the stored flag — a stale policy is a much smaller
107+
problem than a lane that stops selecting anything."""
108+
try:
109+
spec = importlib.util.spec_from_file_location("entity_key", ENTITY_KEY)
110+
mod = importlib.util.module_from_spec(spec)
111+
spec.loader.exec_module(mod)
112+
return mod.is_volatile
113+
except Exception as e: # noqa: BLE001 - never break the lane over this
114+
warn(f"entity_key module unavailable ({e}); using stored volatile flags")
115+
return None
116+
117+
118+
# ---- evidence independence ---------------------------------------------------
119+
120+
# www.pulumi.com is this repo rendered and `content/` is its source, so a
121+
# verdict citing only those has checked the page against itself: it can never
122+
# go stale, and a `contradicted` from the same place is equally meaningless.
123+
# Demoted to `unverifiable` — reported, never counted fresh, never marked.
124+
_URL_RE = re.compile(r"https?://[^\s,;)\]]+", re.IGNORECASE)
125+
_OWN_HOST_RE = re.compile(r"^https?://(?:[\w-]+\.)*pulumi\.com(?:[/:?#]|$)", re.IGNORECASE)
126+
_PATH_RE = re.compile(
127+
r"(?<![\w/-])[\w][\w./-]*\.(?:json|ya?ml|md|mdx|go|ts|tsx|js|py|cs|java|tf|toml)\b"
128+
)
129+
130+
131+
def source_is_own_corpus(source: str) -> bool:
132+
"""True when every source the verifier cited is our own published docs.
133+
134+
Positive evidence only: a source naming nothing identifiable (no URL, no
135+
file path) is left alone rather than demoted — unrecognized is not the
136+
same as circular.
137+
"""
138+
src = source or ""
139+
urls = _URL_RE.findall(src)
140+
if any(not _OWN_HOST_RE.match(u) for u in urls):
141+
return False
142+
paths = _PATH_RE.findall(_URL_RE.sub(" ", src))
143+
own_paths = [p for p in paths if p.startswith("content/") or "/content/" in p]
144+
if len(paths) > len(own_paths):
145+
return False
146+
return bool(urls) or bool(own_paths)
147+
148+
89149
# ---- index loading -----------------------------------------------------------
90150

91151

@@ -105,16 +165,20 @@ def load_snapshots(claims_dir: Path) -> list[dict]:
105165
return out
106166

107167

108-
def volatile_entities(snapshots: list[dict]) -> dict[str, list[dict]]:
168+
def volatile_entities(snapshots: list[dict], is_volatile=None) -> dict[str, list[dict]]:
109169
"""{entity_key: [assertion, ...]} over every volatile keyed claim.
110170
111171
Each assertion carries the claim record plus its page provenance
112172
(path/slug/reviewed_at), so a stale verdict can fan back out to every
113-
page asserting the entity."""
173+
page asserting the entity.
174+
175+
`is_volatile` re-derives the flag under today's policy (see
176+
`_load_is_volatile`); omitting it reads the snapshot's stored flag."""
177+
decide = is_volatile or (lambda c: bool(c.get("volatile")))
114178
entities: dict[str, list[dict]] = {}
115179
for snap in snapshots:
116180
for c in snap.get("claims") or []:
117-
if not isinstance(c, dict) or not c.get("entity_key") or not c.get("volatile"):
181+
if not isinstance(c, dict) or not c.get("entity_key") or not decide(c):
118182
continue
119183
entities.setdefault(c["entity_key"], []).append({
120184
"claim": c,
@@ -239,7 +303,8 @@ def finish(report: dict, out_path: Path) -> int:
239303
out_path.write_text(json.dumps(report, indent=2) + "\n")
240304
m = report["meta"]
241305
log(f"checked={m['n_checked']} stale={m['n_stale']} fresh={m['n_fresh']} "
242-
f"inconclusive={m['n_inconclusive']} (volatile entities={m['n_entities']}) -> {out_path}")
306+
f"inconclusive={m['n_inconclusive']} (of which {m['n_demoted']} demoted for "
307+
f"own-corpus evidence) (volatile entities={m['n_entities']}) -> {out_path}")
243308
write_outputs(report)
244309
return 0
245310

@@ -258,15 +323,17 @@ def run(args) -> int:
258323
"checked_at": today.isoformat(),
259324
"entities": [],
260325
"meta": {"n_snapshots": 0, "n_entities": 0, "n_due": 0, "n_checked": 0,
261-
"n_stale": 0, "n_fresh": 0, "n_inconclusive": 0},
326+
"n_stale": 0, "n_fresh": 0, "n_inconclusive": 0, "n_demoted": 0},
262327
}
263328
out_path = Path(args.out)
264329

265330
# The meta block doubles as the health observation consumed by
266331
# signal-health.py's reverify signal: `skipped` distinguishes "couldn't
267-
# run" (degraded) from the quiet-night n_due=0 (healthy), and an
268-
# all-inconclusive n_checked is the broken-verifier tell. Keep those
269-
# semantics intact when touching the early-exit paths below.
332+
# run" (degraded) from the quiet-night n_due=0 (healthy), an
333+
# all-inconclusive n_checked is the no-drift-detected tell, and n_demoted
334+
# says how much of that was own-corpus evidence rather than broken
335+
# plumbing — the two want opposite fixes. Keep those semantics intact when
336+
# touching the early-exit paths below.
270337
snapshots = load_snapshots(Path(args.claims_dir))
271338
report["meta"]["n_snapshots"] = len(snapshots)
272339
if not snapshots:
@@ -275,7 +342,7 @@ def run(args) -> int:
275342
return finish(report, out_path)
276343

277344
ledger = load_ledger(Path(args.ledger_dir))
278-
entities = volatile_entities(snapshots)
345+
entities = volatile_entities(snapshots, _load_is_volatile())
279346
report["meta"]["n_entities"] = len(entities)
280347

281348
unmarked = sorted(k for k, v in entities.items() if not already_marked(k, v, ledger))
@@ -299,9 +366,14 @@ def check_entity(key: str) -> dict:
299366
claim["__id"] = key
300367
claim["__route"] = vc.route_claim(claim, {})
301368
rec, err = vc.process_claim(api_key, claim, {}, args.model, repo_root, args.dry_run)
369+
verdict = rec.get("verdict")
370+
demoted_from = None
371+
if verdict in DECIDED_VERDICTS and source_is_own_corpus(rec.get("source") or ""):
372+
demoted_from, verdict = verdict, "unverifiable"
302373
return {
303374
"entity_key": key,
304-
"verdict": rec.get("verdict"),
375+
"verdict": verdict,
376+
"demoted_from": demoted_from,
305377
"confidence": rec.get("confidence"),
306378
"evidence": rec.get("evidence"),
307379
"source": rec.get("source"),
@@ -320,6 +392,7 @@ def check_entity(key: str) -> dict:
320392
report["meta"]["n_stale"] = len(stale)
321393
report["meta"]["n_fresh"] = len(fresh)
322394
report["meta"]["n_inconclusive"] = len(results) - len(stale) - len(fresh)
395+
report["meta"]["n_demoted"] = sum(1 for r in results if r["demoted_from"])
323396

324397
if stale:
325398
changed = apply_markers(ledger, stale, today)
@@ -371,10 +444,43 @@ def snap(slug, reviewed_at, *claims):
371444
ents = volatile_entities(snaps)
372445
check("volatile keyed entities only",
373446
set(ents) == {"version/pulumi-gcp", "numerical/team-plan-price"})
447+
374448
check("entity fans out across pages", len(ents["version/pulumi-gcp"]) == 2)
375449
check("representative is the freshest assertion",
376450
representative(ents["version/pulumi-gcp"])["text"] == "pulumi-gcp v8.3.0")
377451

452+
# Re-derived volatility overrides the snapshot's stored flag, so a
453+
# narrowed policy reaches the whole index the night it ships.
454+
stale_flag = {"entity_key": "numerical/example-resources-created", "volatile": True,
455+
"type": "numerical", "line_range": "L3", "verdict": "verified",
456+
"text": "The example update creates 3 resources."}
457+
rederived = volatile_entities(
458+
snaps + [snap("c", "2026-07-06", stale_flag)], _load_is_volatile())
459+
check("re-derive drops a self-describing claim stamped volatile",
460+
"numerical/example-resources-created" not in rederived)
461+
check("re-derive keeps genuinely volatile entities",
462+
set(rederived) == {"version/pulumi-gcp", "numerical/team-plan-price"})
463+
check("no re-derive falls back to the stored flag",
464+
"numerical/example-resources-created"
465+
in volatile_entities(snaps + [snap("c", "2026-07-06", stale_flag)]))
466+
467+
# Evidence independence: only own-corpus-sourced verdicts are demoted, and
468+
# an unrecognizable source is never treated as circular.
469+
own = ["https://www.pulumi.com/docs/iac/get-started/aws/modify-program/",
470+
"https://www.pulumi.com/docs/a/ and https://www.pulumi.com/docs/b/",
471+
"repo:content/docs/iac/concepts/providers/_index.md"]
472+
independent = ["https://docs.aws.amazon.com/config/latest/developerguide/x.html",
473+
"gh api repos/pulumi/pulumi/contents/pkg/resource/deploy/retries.go",
474+
"repo:data/policy_pack_policies/cis-aws.json (line 2872)",
475+
"https://github.com/pulumi/pulumi/blob/master/.goreleaser.yml",
476+
"https://www.pulumi.com/docs/a/ and https://docs.aws.amazon.com/x.html",
477+
"N/A - author's own estimate of their tutorial content",
478+
""]
479+
for s in own:
480+
check(f"own-corpus source: {s[:44]}", source_is_own_corpus(s) is True)
481+
for s in independent:
482+
check(f"independent source: {s[:44] or '(empty)'}", source_is_own_corpus(s) is False)
483+
378484
# Chunk rotation: deterministic, complete coverage across consecutive days.
379485
keys = [f"k{i}" for i in range(5)]
380486
d0 = date(2026, 7, 6)

0 commit comments

Comments
 (0)