Implement deterministic analysis input manifest
Adds deterministic analysis input manifesting, compact evidence slices, citation inventories, and press cap telemetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Juan Manuel Servera committed
Jun 7, 2026 at 12:43 UTC
0bf812bb4fe79d10f8fb427662946c2a8ab5a7b2
9 files changed
+679
-6
.github/workflows/crawl-and-publish.yml
+7
-3
@@ -454,7 +454,8 @@ jobs:
454
PRESS_FILE="${{ steps.press-context.outputs.press_file }}"
455
DIAGNOSTICS_DIR="$(dirname "$OUTPUT_FILE")/diagnostics"
456
PROMPT_FILE="data/metrics/analysis-prompt-${WEEK}.md"
457
- PREFLIGHT_JSON="$DIAGNOSTICS_DIR/analysis-preflight.json"
457
+ PREFLIGHT_JSON="$DIAGNOSTICS_DIR/analysis-input-manifest.json"
458
+ LEGACY_PREFLIGHT_JSON="$DIAGNOSTICS_DIR/analysis-preflight.json"
459
PREFLIGHT_MD="$DIAGNOSTICS_DIR/analysis-preflight.md"
460
mkdir -p data/metrics "$(dirname "$OUTPUT_FILE")" "$DIAGNOSTICS_DIR"
461
# Hydrate metrics ledger from publish before writing this run's prompt/preflight artifacts.
@@ -469,6 +470,7 @@ jobs:
470
--preflight-report-json "$PREFLIGHT_JSON" \
471
--preflight-report-md "$PREFLIGHT_MD" \
472
--print-prompt > "$PROMPT_FILE"
473
+ cp "$PREFLIGHT_JSON" "$LEGACY_PREFLIGHT_JSON"
474
python3 scripts/preflight_cost_check.py \
475
--context-files "$PROMPT_FILE"
476
python3 - <<'PY' "$PREFLIGHT_JSON"
@@ -507,6 +509,7 @@ jobs:
509
env:
510
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GH_TOKEN }}
511
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
512
+ PREFLIGHT_REPORT_JSON: ${{ steps.prompt-preflight.outputs.preflight_report_json }}
513
run: |
514
set -euo pipefail
515
OUTPUT_FILE="${{ steps.analysis-context.outputs.candidate_output_file }}"
@@ -561,8 +564,8 @@ jobs:
564
--current-datetime "$CURRENT_DATETIME" \
565
--week "$WEEK" \
566
--prompt-file "$PROMPT_FILE" \
564
- --output-file "$OUTPUT_FILE"
565
- echo "::notice::Map/reduce dry-run candidate written to $OUTPUT_FILE with sidecars under $MAP_REDUCE_DIR. It is not publish eligible."
567
+ --output-file "$OUTPUT_FILE" \
568
+ --input-manifest "$PREFLIGHT_REPORT_JSON"
569
rm -f "$PROMPT_FILE"
570
echo "analysis_source=$ANALYSIS_SOURCE" >> "$GITHUB_OUTPUT"
571
echo "analysis_model=$ANALYSIS_MODEL" >> "$GITHUB_OUTPUT"
@@ -725,6 +728,7 @@ jobs:
728
--week "$WEEK" \
729
--prompt-file "$PROMPT_FILE" \
730
--output-file "$OUTPUT_FILE" \
731
+ --input-manifest "$PREFLIGHT_REPORT_JSON" \
732
$TRANSCRIPT_ARGS
733
ANALYSIS_DURATION=$(( $(date +%s) - ANALYSIS_STARTED ))
734
echo "::notice::Analysis path source=$ANALYSIS_SOURCE model=$ANALYSIS_MODEL duration_seconds=$ANALYSIS_DURATION press_context=$PRESS_FILE"
scripts/analyze_fallback.py
+359
@@ -5,6 +5,7 @@ import argparse
5
import hashlib
6
import json
7
import os
8
+import re
9
import secrets
10
import sys
11
import time
@@ -55,9 +56,24 @@ class PromptComponent:
56
class EvidenceRepoRef:
57
full_name: str
58
url: str | None
59
+ description: str | None
60
+ language: str | None
61
+ topics: list[str]
62
source: str
63
stars: int | None
64
stars_gained: int | None
65
+ created_at: str | None
66
+
67
+
68
+@dataclass
69
+class EvidencePressRef:
70
+ title: str | None
71
+ url: str
72
+ source: str | None
73
+ published_at: str | None
74
+ categories: list[str]
75
+ relevance_score: float | None
76
+ correlation_repos: list[str]
77
78
79
@dataclass
@@ -71,12 +87,37 @@ class EvidenceInventory:
87
repos: list[EvidenceRepoRef]
88
89
90
+@dataclass
91
+class PressInventory:
92
+ name: str
93
+ path: str | None
94
+ item_count: int
95
+ bytes: int
96
+ token_estimate: int
97
+ checksum_sha256: str
98
+ articles: list[EvidencePressRef]
99
+
100
+
101
+@dataclass
102
+class EvidenceSliceRef:
103
+ name: str
104
+ path: str | None
105
+ item_count: int
106
+ bytes: int
107
+ token_estimate: int
108
+ checksum_sha256: str
109
+ provenance: dict[str, Any]
110
+ validation_errors: list[str]
111
+
112
+
113
@dataclass
114
class PromptPreflight:
115
+ schema_version: str
116
prompt_token_budget: int
117
prompt_tokens: int
118
prompt_bytes: int
119
prompt_checksum_sha256: str
120
+ rendered_prompt_estimate: dict[str, int | str]
121
prompt_within_budget: bool
122
degraded: bool
123
publish_eligible: bool
@@ -85,7 +126,10 @@ class PromptPreflight:
126
fallback_policy: str
127
components: list[PromptComponent]
128
deterministic_slices: list[str]
129
+ generated_evidence_slices: list[EvidenceSliceRef]
130
+ evidence_slice_payloads: dict[str, dict[str, Any]]
131
evidence_inventories: list[EvidenceInventory]
132
+ press_inventories: list[PressInventory]
133
134
135
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
@@ -165,6 +209,14 @@ def checksum_text(text: str) -> str:
209
return hashlib.sha256(text.encode("utf-8")).hexdigest()
210
211
212
+def stable_json(payload: Any) -> str:
213
+ return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
214
+
215
+
216
+def checksum_payload(payload: Any) -> str:
217
+ return checksum_text(stable_json(payload))
218
+
219
+
220
def _component(
221
*,
222
name: str,
@@ -190,6 +242,40 @@ def _repo_int(value: Any) -> int | None:
242
return value if isinstance(value, int) and not isinstance(value, bool) else None
243
244
245
+def _repo_topics(value: Any) -> list[str]:
246
+ if not isinstance(value, list):
247
+ return []
248
+ return [str(topic) for topic in value if isinstance(topic, (str, int, float)) and str(topic).strip()]
249
+
250
+
251
+REQUIRED_REPO_SLICE_FIELDS = (
252
+ "full_name",
253
+ "url",
254
+ "description",
255
+ "language",
256
+ "topics",
257
+ "stars",
258
+ "stars_gained",
259
+ "created_at",
260
+)
261
+
262
+
263
+def compact_repo_record(repo: dict[str, Any], *, source: str) -> dict[str, Any]:
264
+ full_name = str(repo.get("full_name") or "").strip()
265
+ url = repo.get("url")
266
+ return {
267
+ "full_name": full_name,
268
+ "url": url if isinstance(url, str) and url.strip() else (f"https://github.com/{full_name}" if full_name else None),
269
+ "description": repo.get("description") if isinstance(repo.get("description"), str) else None,
270
+ "language": repo.get("language") if isinstance(repo.get("language"), str) else None,
271
+ "topics": _repo_topics(repo.get("topics")),
272
+ "stars": _repo_int(repo.get("stars")),
273
+ "stars_gained": _repo_int(repo.get("stars_gained")),
274
+ "created_at": repo.get("created_at") if isinstance(repo.get("created_at"), str) else None,
275
+ "source": source,
276
+ }
277
+
278
+
279
def _inventory_repo_refs(payload: dict[str, Any], field: str) -> list[EvidenceRepoRef]:
280
repos = payload.get(field)
281
if not isinstance(repos, list):
@@ -206,9 +292,13 @@ def _inventory_repo_refs(payload: dict[str, Any], field: str) -> list[EvidenceRe
292
EvidenceRepoRef(
293
full_name=full_name.strip(),
294
url=url if isinstance(url, str) and url.strip() else None,
295
+ description=repo.get("description") if isinstance(repo.get("description"), str) else None,
296
+ language=repo.get("language") if isinstance(repo.get("language"), str) else None,
297
+ topics=_repo_topics(repo.get("topics")),
298
source=field,
299
stars=_repo_int(repo.get("stars")),
300
stars_gained=_repo_int(repo.get("stars_gained")),
301
+ created_at=repo.get("created_at") if isinstance(repo.get("created_at"), str) else None,
302
)
303
)
304
return refs
@@ -228,6 +318,251 @@ def _evidence_inventory(name: str, payload: dict[str, Any], field: str, path: Pa
318
)
319
320
321
+def _press_paths_for_context(press_context_path: Path | None, week: str) -> tuple[Path | None, Path | None]:
322
+ if press_context_path is None:
323
+ return None, None
324
+ data_dir = press_context_path.parent.parent
325
+ external_path = data_dir / "raw" / f"{week}-external-news.json"
326
+ legacy_path = data_dir / "raw" / f"{week}-techcrunch.json"
327
+ corr_path = data_dir / "analyzed" / f"{week}-correlations.json"
328
+ news_path = external_path if external_path.exists() else legacy_path if legacy_path.exists() else None
329
+ return news_path, corr_path if corr_path.exists() else None
330
+
331
+
332
+def _safe_load_json(path: Path | None) -> dict[str, Any] | None:
333
+ if path is None or not path.exists():
334
+ return None
335
+ try:
336
+ payload = json.loads(path.read_text(encoding="utf-8"))
337
+ except (OSError, json.JSONDecodeError):
338
+ return None
339
+ return payload if isinstance(payload, dict) else None
340
+
341
+
342
+def _article_inventory(news_payload: dict[str, Any] | None, correlation_payload: dict[str, Any] | None, path: Path | None) -> PressInventory:
343
+ articles = news_payload.get("articles", []) if news_payload else []
344
+ correlations = correlation_payload.get("correlations", []) if correlation_payload else []
345
+ repo_by_url: dict[str, set[str]] = {}
346
+ for corr in correlations if isinstance(correlations, list) else []:
347
+ if not isinstance(corr, dict):
348
+ continue
349
+ repo = corr.get("repo")
350
+ for url in corr.get("matched_articles", []) if isinstance(corr.get("matched_articles"), list) else []:
351
+ if isinstance(url, str) and isinstance(repo, str):
352
+ repo_by_url.setdefault(url, set()).add(repo)
353
+ for detail in corr.get("matched_article_details", []) if isinstance(corr.get("matched_article_details"), list) else []:
354
+ if isinstance(detail, dict) and isinstance(detail.get("url"), str) and isinstance(repo, str):
355
+ repo_by_url.setdefault(detail["url"], set()).add(repo)
356
+ refs: list[EvidencePressRef] = []
357
+ for article in articles if isinstance(articles, list) else []:
358
+ if not isinstance(article, dict) or not isinstance(article.get("url"), str) or not article["url"].strip():
359
+ continue
360
+ categories = article.get("categories") if isinstance(article.get("categories"), list) else []
361
+ relevance = article.get("relevance_score")
362
+ refs.append(
363
+ EvidencePressRef(
364
+ title=article.get("title") if isinstance(article.get("title"), str) else None,
365
+ url=article["url"],
366
+ source=article.get("source") if isinstance(article.get("source"), str) else None,
367
+ published_at=article.get("published_at") if isinstance(article.get("published_at"), str) else None,
368
+ categories=[str(category) for category in categories],
369
+ relevance_score=float(relevance) if isinstance(relevance, (int, float)) and not isinstance(relevance, bool) else None,
370
+ correlation_repos=sorted(repo_by_url.get(article["url"], set())),
371
+ )
372
+ )
373
+ content = stable_json([asdict(ref) for ref in refs])
374
+ return PressInventory(
375
+ name="press_articles",
376
+ path=path.as_posix() if path else None,
377
+ item_count=len(refs),
378
+ bytes=len(content.encode("utf-8")),
379
+ token_estimate=estimate_tokens(content),
380
+ checksum_sha256=checksum_text(content),
381
+ articles=refs,
382
+ )
383
+
384
+
385
+def _source_ref(path: Path | None, content: str | None = None) -> dict[str, Any] | None:
386
+ if path is None and content is None:
387
+ return None
388
+ if content is None:
389
+ if path is None or not path.exists():
390
+ return None
391
+ data = path.read_bytes()
392
+ return {"path": path.as_posix(), "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
393
+ encoded = content.encode("utf-8")
394
+ return {
395
+ "path": path.as_posix() if path else None,
396
+ "bytes": len(encoded),
397
+ "sha256": checksum_text(content),
398
+ }
399
+
400
+
401
+def _slice_checksum_payload(payload: dict[str, Any]) -> dict[str, Any]:
402
+ stripped = dict(payload)
403
+ stripped.pop("checksum_sha256", None)
404
+ return stripped
405
+
406
+
407
+def validate_evidence_slice(payload: dict[str, Any], *, expected_checksum: str | None = None) -> list[str]:
408
+ errors: list[str] = []
409
+ for field in ("schema_version", "slice_name", "component", "records", "provenance", "checksum_sha256"):
410
+ if field not in payload:
411
+ errors.append(f"slice missing {field}")
412
+ checksum = payload.get("checksum_sha256")
413
+ if isinstance(checksum, str):
414
+ actual = checksum_payload(_slice_checksum_payload(payload))
415
+ if checksum != actual:
416
+ errors.append("slice checksum mismatch")
417
+ if expected_checksum is not None and checksum != expected_checksum:
418
+ errors.append("slice checksum does not match manifest reference")
419
+ elif "checksum_sha256" in payload:
420
+ errors.append("slice checksum_sha256 must be a string")
421
+ records = payload.get("records")
422
+ if not isinstance(records, list):
423
+ errors.append("slice records must be a list")
424
+ records = []
425
+ provenance = payload.get("provenance")
426
+ if not isinstance(provenance, dict):
427
+ errors.append("slice provenance must be an object")
428
+ else:
429
+ sources = provenance.get("sources")
430
+ if not isinstance(sources, dict) or not sources:
431
+ errors.append("slice provenance sources missing")
432
+ else:
433
+ for name, source in sources.items():
434
+ if not isinstance(source, dict) or not source.get("sha256") or not isinstance(source.get("bytes"), int):
435
+ errors.append(f"slice provenance source {name} missing checksum/bytes")
436
+ if payload.get("component") in {"new_repos", "trending_repos"}:
437
+ for index, record in enumerate(records):
438
+ if not isinstance(record, dict):
439
+ errors.append(f"record {index} must be an object")
440
+ continue
441
+ for field in REQUIRED_REPO_SLICE_FIELDS:
442
+ if field not in record:
443
+ errors.append(f"record {index} missing {field}")
444
+ return errors
445
+
446
+
447
+def _build_slice(name: str, records: list[dict[str, Any]], provenance: dict[str, Any]) -> dict[str, Any]:
448
+ payload = {
449
+ "schema_version": "analysis_evidence_slice_v1",
450
+ "slice_name": name,
451
+ "component": name,
452
+ "records": records,
453
+ "provenance": provenance,
454
+ }
455
+ payload["checksum_sha256"] = checksum_payload(payload)
456
+ return payload
457
+
458
+
459
+def build_evidence_slices(
460
+ *,
461
+ week: str,
462
+ raw_path: Path,
463
+ sanitized_payload: dict[str, Any],
464
+ payload_for_prompt: dict[str, Any],
465
+ press_context_path: Path | None,
466
+ press_content: str,
467
+ previous_summary_path: Path | None,
468
+ previous_summary_content: str,
469
+) -> dict[str, dict[str, Any]]:
470
+ raw_source = _source_ref(raw_path)
471
+ press_source = _source_ref(press_context_path, press_content) if press_content else None
472
+ previous_source = _source_ref(previous_summary_path, previous_summary_content) if previous_summary_content else None
473
+ news_path, corr_path = _press_paths_for_context(press_context_path, week)
474
+ news_payload = _safe_load_json(news_path)
475
+ corr_payload = _safe_load_json(corr_path)
476
+ news_source = _source_ref(news_path)
477
+ corr_source = _source_ref(corr_path)
478
+ base_provenance = {"week": week, "sources": {"raw_json": raw_source} if raw_source else {}}
479
+ slices = {
480
+ "new_repos": _build_slice(
481
+ "new_repos",
482
+ [compact_repo_record(repo, source="new_repos") for repo in payload_for_prompt.get("new_repos", []) if isinstance(repo, dict)],
483
+ base_provenance,
484
+ ),
485
+ "trending_repos": _build_slice(
486
+ "trending_repos",
487
+ [
488
+ compact_repo_record(repo, source="trending_repos")
489
+ for repo in payload_for_prompt.get("trending_repos", [])
490
+ if isinstance(repo, dict)
491
+ ],
492
+ base_provenance,
493
+ ),
494
+ }
495
+ press_sources = {}
496
+ for key, source in (("raw_json", raw_source), ("press_context", press_source), ("external_news", news_source), ("correlations", corr_source)):
497
+ if source:
498
+ press_sources[key] = source
499
+ press_records: list[dict[str, Any]] = []
500
+ correlations = corr_payload.get("correlations", []) if corr_payload else []
501
+ for corr in correlations if isinstance(correlations, list) else []:
502
+ if isinstance(corr, dict):
503
+ press_records.append(
504
+ {
505
+ "repo": corr.get("repo"),
506
+ "matched_articles": corr.get("matched_articles", []),
507
+ "matched_article_details": corr.get("matched_article_details", []),
508
+ "match_type": corr.get("match_type"),
509
+ "correlation_confidence": corr.get("correlation_confidence"),
510
+ "correlation_strength": corr.get("correlation_strength"),
511
+ "hype_risk": corr.get("hype_risk"),
512
+ }
513
+ )
514
+ if not press_records and press_content:
515
+ urls = sorted(set(re.findall(r"https?://[^\s)\]]+", press_content)))
516
+ press_records = [{"url": url.rstrip(".,"), "source": "rendered_press_context"} for url in urls]
517
+ slices["press_correlations"] = _build_slice(
518
+ "press_correlations",
519
+ press_records,
520
+ {"week": week, "sources": press_sources},
521
+ )
522
+ prior_sources = {"raw_json": raw_source} if raw_source else {}
523
+ if previous_source:
524
+ prior_sources["prior_summary"] = previous_source
525
+ slices["prior_continuity"] = _build_slice(
526
+ "prior_continuity",
527
+ [
528
+ {
529
+ "source_path": previous_summary_path.as_posix() if previous_summary_path else None,
530
+ "present": bool(previous_summary_content),
531
+ "excerpt": previous_summary_content[:1000],
532
+ }
533
+ ],
534
+ {"week": week, "sources": prior_sources},
535
+ )
536
+ return slices
537
+
538
+
539
+def write_evidence_slices(slices: dict[str, dict[str, Any]], manifest_path: Path | None) -> list[EvidenceSliceRef]:
540
+ refs: list[EvidenceSliceRef] = []
541
+ output_dir = manifest_path.parent / "evidence-slices" if manifest_path else None
542
+ if output_dir:
543
+ output_dir.mkdir(parents=True, exist_ok=True)
544
+ for name in sorted(slices):
545
+ payload = slices[name]
546
+ checksum = str(payload["checksum_sha256"])
547
+ text = stable_json(payload)
548
+ path = output_dir / f"{name}-{checksum[:12]}.json" if output_dir else None
549
+ if path:
550
+ path.write_text(text, encoding="utf-8")
551
+ refs.append(
552
+ EvidenceSliceRef(
553
+ name=name,
554
+ path=path.as_posix() if path else None,
555
+ item_count=len(payload.get("records", [])) if isinstance(payload.get("records"), list) else 0,
556
+ bytes=len(text.encode("utf-8")),
557
+ token_estimate=estimate_tokens(text),
558
+ checksum_sha256=checksum,
559
+ provenance=payload.get("provenance", {}) if isinstance(payload.get("provenance"), dict) else {},
560
+ validation_errors=validate_evidence_slice(payload),
561
+ )
562
+ )
563
+ return refs
564
+
565
+
566
def truncate_with_notice(content: str, limit: int, label: str) -> tuple[str, str]:
567
if len(content) <= limit:
568
return content, "included"
@@ -516,11 +851,30 @@ def _build_prompt(
851
degradation_reason = (
852
"Prompt was deterministically compacted to fit the configured token budget." if degraded else None
853
)
854
+ evidence_slices = build_evidence_slices(
855
+ week=current_week,
856
+ raw_path=raw_json_path,
857
+ sanitized_payload=sanitized_payload,
858
+ payload_for_prompt=payload_for_prompt,
859
+ press_context_path=press_context_path,
860
+ press_content=press_content,
861
+ previous_summary_path=previous_summary_path,
862
+ previous_summary_content=previous_summary_content,
863
+ )
864
+ news_path, corr_path = _press_paths_for_context(press_context_path, current_week)
865
+ press_inventory = _article_inventory(_safe_load_json(news_path), _safe_load_json(corr_path), news_path)
866
+ slice_refs = write_evidence_slices(evidence_slices, None)
867
preflight = PromptPreflight(
868
+ schema_version="analysis_input_manifest_v1",
869
prompt_token_budget=prompt_token_budget,
870
prompt_tokens=prompt_tokens,
871
prompt_bytes=len(prompt.encode("utf-8")),
872
prompt_checksum_sha256=checksum_text(prompt),
873
+ rendered_prompt_estimate={
874
+ "bytes": len(prompt.encode("utf-8")),
875
+ "tokens": prompt_tokens,
876
+ "checksum_sha256": checksum_text(prompt),
877
+ },
878
prompt_within_budget=prompt_within_budget,
879
degraded=degraded,
880
publish_eligible=prompt_within_budget and not degraded,
@@ -536,12 +890,15 @@ def _build_prompt(
890
),
891
components=components,
892
deterministic_slices=["new_repos", "trending_repos", "press_correlations", "prior_continuity"],
893
+ generated_evidence_slices=slice_refs,
894
+ evidence_slice_payloads=evidence_slices,
895
evidence_inventories=[
896
_evidence_inventory("raw_new_repos", sanitized_payload, "new_repos", raw_json_path),
897
_evidence_inventory("raw_trending_repos", sanitized_payload, "trending_repos", raw_json_path),
898
_evidence_inventory("prompt_new_repos", payload_for_prompt, "new_repos", raw_json_path),
899
_evidence_inventory("prompt_trending_repos", payload_for_prompt, "trending_repos", raw_json_path),
900
],
901
+ press_inventories=[press_inventory],
902
)
903
return prompt, preflight
904
@@ -572,6 +929,8 @@ def render_prompt(
929
930
931
def write_preflight_reports(preflight: PromptPreflight, json_path: Path | None, md_path: Path | None) -> None:
932
+ if json_path and preflight.evidence_slice_payloads:
933
+ preflight.generated_evidence_slices = write_evidence_slices(preflight.evidence_slice_payloads, json_path)
934
if json_path:
935
json_path.parent.mkdir(parents=True, exist_ok=True)
936
json_path.write_text(json.dumps(asdict(preflight), indent=2, sort_keys=True) + "\n", encoding="utf-8")
scripts/map_reduce_dry_run.py
+55
-1
@@ -163,6 +163,13 @@ def make_repo_finding(repo: dict[str, Any], *, mapper: str, category: str, role:
163
"type": "repo",
164
"ref": full_name,
165
"url": repo_url(repo, full_name),
166
+ "full_name": full_name,
167
+ "description": repo.get("description"),
168
+ "language": repo.get("language"),
169
+ "topics": topics,
170
+ "stars": stars,
171
+ "stars_gained": gained,
172
+ "created_at": repo.get("created_at"),
173
"role": "anchor",
174
"evidence_note": f"Crawler metrics show {metric_note}.",
175
}
@@ -751,8 +758,17 @@ def run(args: argparse.Namespace) -> dict[str, Path]:
758
out = args.output_dir
759
maps_dir = out / "maps"
760
sidecars_dir = out / "sidecars"
761
+ evidence_slices_dir = sidecars_dir / "evidence-slices"
762
+ evidence_slice_refs: dict[str, dict[str, Any]] = {}
763
for name, payload in maps.items():
755
- write_json(maps_dir / f"{name}.json", payload)
764
+ map_path = maps_dir / f"{name}.json"
765
+ write_json(map_path, payload)
766
+ map_ref = file_ref(map_path)
767
+ if map_ref:
768
+ addressed_path = evidence_slices_dir / f"{name}-{map_ref.sha256[:12]}.json"
769
+ write_json(addressed_path, payload)
770
+ addressed_ref = file_ref(addressed_path)
771
+ evidence_slice_refs[name] = asdict(addressed_ref) if addressed_ref else {}
772
write_json(out / "editorial-plan.json", plan)
773
write_json(sidecars_dir / "rejected-claims.json", {"schema_version": "analysis_rejected_claims_v1", "week": week, "rejected_claims": rejected})
774
write_json(sidecars_dir / "contradictions.json", {"schema_version": "analysis_contradictions_v1", "week": week, "contradictions": contradictions})
@@ -770,6 +786,11 @@ def run(args: argparse.Namespace) -> dict[str, Path]:
786
model=args.analysis_model,
787
)
788
write_json(out / "qa-comparison-report.json", qa)
789
+ raw_component = file_ref(args.raw_json)
790
+ press_component = file_ref(args.press_context)
791
+ template_component = file_ref(ROOT / "prompts" / "analyze-weekly.md")
792
+ prior_component = file_ref(previous_summary)
793
+ slice_components = {name: ref for name, ref in evidence_slice_refs.items()}
794
manifest = {
795
"schema_version": "analysis_map_reduce_dry_run_manifest_v1",
796
"week": week,
@@ -779,12 +800,45 @@ def run(args: argparse.Namespace) -> dict[str, Path]:
800
"candidate_only": True,
801
"artifacts": {
802
"maps": {name: (maps_dir / f"{name}.json").as_posix() for name in MAPPER_IDS},
803
+ "evidence_slices": {name: ref.get("path") for name, ref in evidence_slice_refs.items()},
804
"editorial_plan": (out / "editorial-plan.json").as_posix(),
805
"rejected_claims": (sidecars_dir / "rejected-claims.json").as_posix(),
806
"contradictions": (sidecars_dir / "contradictions.json").as_posix(),
807
"candidate": candidate_path.as_posix(),
808
"qa_report": (out / "qa-comparison-report.json").as_posix(),
809
},
810
+ "component_estimates": {
811
+ "raw_json": asdict(raw_component) if raw_component else None,
812
+ "press_context": asdict(press_component) if press_component else None,
813
+ "prompt_template": asdict(template_component) if template_component else None,
814
+ "prior_continuity": asdict(prior_component) if prior_component else None,
815
+ "generated_evidence_slices": slice_components,
816
+ "rendered_prompt_estimate": {
817
+ "bytes": len(candidate_text.encode("utf-8")),
818
+ "tokens": estimate_tokens(candidate_text),
819
+ "checksum_sha256": sha256_bytes(candidate_text.encode("utf-8")),
820
+ },
821
+ },
822
+ "citation_inventories": {
823
+ "repos": sorted(
824
+ {
825
+ ref.get("ref")
826
+ for payload in maps.values()
827
+ for finding in payload.get("findings", [])
828
+ for ref in finding.get("evidence_refs", [])
829
+ if isinstance(ref, dict) and ref.get("type") == "repo" and ref.get("ref")
830
+ }
831
+ ),
832
+ "press_articles": sorted(
833
+ {
834
+ ref.get("url")
835
+ for payload in maps.values()
836
+ for finding in payload.get("findings", [])
837
+ for ref in finding.get("evidence_refs", [])
838
+ if isinstance(ref, dict) and ref.get("type") == "article" and ref.get("url")
839
+ }
840
+ ),
841
+ },
842
"promotion_policy": "blocked: dry-run/candidate-only map/reduce output must not write data/analyzed, content/weekly, deploy, notify, or satisfy publish eligibility.",
843
}
844
write_json(out / "manifest.json", manifest)
scripts/render_press_context.py
+23
@@ -559,6 +559,19 @@ def _source_caveats(techcrunch_data: dict | None, correlation_data: dict | None)
559
return "\n".join(lines)
560
561
562
+def _source_coverage(techcrunch_data: dict | None, correlation_data: dict | None) -> dict[str, list[str]]:
563
+ metadata = techcrunch_data.get("metadata", {}) if techcrunch_data else {}
564
+ corr_sources = correlation_data.get("metadata", {}).get("news_sources", {}) if correlation_data else {}
565
+ requested = metadata.get("sources_requested") or corr_sources.get("sources_requested") or []
566
+ succeeded = metadata.get("sources_succeeded") or corr_sources.get("sources_succeeded") or []
567
+ failed = metadata.get("sources_failed") or corr_sources.get("sources_failed") or []
568
+ return {
569
+ "requested": [str(item) for item in requested],
570
+ "succeeded": [str(item) for item in succeeded],
571
+ "failed": [str(item) for item in failed],
572
+ }
573
+
574
+
575
def estimate_tokens(markdown: str) -> int:
576
"""Return a rough token estimate used for telemetry and hard budget checks."""
577
return max(1, (len(markdown) + 3) // 4)
@@ -680,7 +693,17 @@ def render_press_context(
693
f"- token_estimate: {estimate_tokens(rendered)}\n"
694
f"- token_budget: {PRESS_CONTEXT_TOKEN_BUDGET}\n"
695
f"- article_limit: {MAX_RENDERED_ARTICLES}\n"
696
+ f"- articles_retained: {min(article_count, MAX_RENDERED_ARTICLES)}\n"
697
+ f"- articles_dropped: {max(0, article_count - MAX_RENDERED_ARTICLES)}\n"
698
f"- correlation_limit: {MAX_RENDERED_CORRELATIONS if reader_mode else 'unbounded-input'}\n"
699
+ f"- correlations_retained: {min(correlation_count, MAX_RENDERED_CORRELATIONS) if reader_mode else correlation_count}\n"
700
+ f"- correlations_dropped: {max(0, correlation_count - MAX_RENDERED_CORRELATIONS) if reader_mode else 0}\n"
701
+ )
702
+ coverage = _source_coverage(techcrunch_data, correlation_data)
703
+ rendered += (
704
+ f"- sources_requested: {', '.join(coverage['requested']) if coverage['requested'] else 'unknown'}\n"
705
+ f"- sources_succeeded: {', '.join(coverage['succeeded']) if coverage['succeeded'] else 'unknown'}\n"
706
+ f"- sources_failed: {', '.join(coverage['failed']) if coverage['failed'] else 'none'}\n"
707
)
708
709
return enforce_press_context_budget(rendered)
scripts/track_token_usage.py
+61
-2
@@ -5,6 +5,7 @@ import argparse
5
import json
6
import math
7
import re
8
+import sys
9
from datetime import UTC, datetime
10
from pathlib import Path
11
@@ -35,6 +36,11 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
36
parser.add_argument("--output-tokens", type=int, help="Explicit output token count.")
37
parser.add_argument("--transcript", type=Path, help="Copilot CLI --share transcript file for parsing token usage.")
38
parser.add_argument("--api-response", type=Path, help="GitHub Models API response JSON for extracting usage data.")
39
+ parser.add_argument(
40
+ "--input-manifest",
41
+ type=Path,
42
+ help="analysis-input-manifest JSON used to validate final prompt input tokens within 10%.",
43
+ )
44
parser.add_argument("--usage-file", type=Path, default=DEFAULT_USAGE_FILE, help="JSONL path for usage ledger.")
45
return parser.parse_args(argv)
46
@@ -179,7 +185,7 @@ def build_record(args: argparse.Namespace) -> dict[str, object]:
185
output_tokens = estimate_tokens_from_path(args.output_file)
186
187
cost = estimate_cost_usd(args.model, input_tokens, output_tokens)
182
- return {
188
+ record: dict[str, object] = {
189
"timestamp": parsed_datetime.isoformat().replace("+00:00", "Z"),
190
"month": parsed_datetime.strftime("%Y-%m"),
191
"week": week,
@@ -192,6 +198,51 @@ def build_record(args: argparse.Namespace) -> dict[str, object]:
198
"cost_usd": cost,
199
"estimated": estimated,
200
}
201
+ validation = validate_input_manifest(args.input_manifest, input_tokens)
202
+ if validation is not None:
203
+ record["input_manifest_validation"] = validation
204
+ return record
205
+
206
+
207
+def _manifest_prompt_tokens(manifest: dict[str, object]) -> int | None:
208
+ rendered = manifest.get("rendered_prompt_estimate")
209
+ if isinstance(rendered, dict) and isinstance(rendered.get("tokens"), int):
210
+ return int(rendered["tokens"])
211
+ value = manifest.get("prompt_tokens")
212
+ return int(value) if isinstance(value, int) else None
213
+
214
+
215
+def validate_input_manifest(path: Path | None, input_tokens: int) -> dict[str, object] | None:
216
+ if path is None:
217
+ return None
218
+ manifest = json.loads(path.read_text(encoding="utf-8"))
219
+ if not isinstance(manifest, dict):
220
+ raise ValueError(f"Input manifest must be an object: {path}")
221
+ estimated_tokens = _manifest_prompt_tokens(manifest)
222
+ if estimated_tokens is None:
223
+ raise ValueError(f"Input manifest missing rendered prompt token estimate: {path}")
224
+ delta = abs(input_tokens - estimated_tokens)
225
+ ratio = delta / max(input_tokens, 1)
226
+ degraded = bool(manifest.get("degraded")) or not bool(manifest.get("prompt_within_budget", True))
227
+ passed = ratio <= 0.10
228
+ reason = None
229
+ if not passed:
230
+ reason = (
231
+ f"Final input usage differs from manifest by {ratio:.1%} "
232
+ f"({input_tokens} actual vs {estimated_tokens} estimated)."
233
+ )
234
+ if degraded:
235
+ reason += " Manifest is degraded/compacted, so the run is already marked candidate-only."
236
+ return {
237
+ "manifest_path": path.as_posix(),
238
+ "estimated_input_tokens": estimated_tokens,
239
+ "actual_input_tokens": input_tokens,
240
+ "delta_tokens": delta,
241
+ "delta_ratio": round(ratio, 6),
242
+ "within_10_percent": passed,
243
+ "degraded_or_compacted": degraded,
244
+ "reason": reason,
245
+ }
246
247
248
def append_record(path: Path, record: dict[str, object]) -> None:
@@ -203,7 +254,15 @@ def append_record(path: Path, record: dict[str, object]) -> None:
254
255
def main(argv: list[str] | None = None) -> int:
256
args = parse_args(argv)
206
- record = build_record(args)
257
+ try:
258
+ record = build_record(args)
259
+ except (OSError, json.JSONDecodeError, ValueError) as exc:
260
+ print(f"::error::Token usage manifest validation failed: {exc}", file=sys.stderr)
261
+ return 1
262
+ validation = record.get("input_manifest_validation")
263
+ if isinstance(validation, dict) and not validation.get("within_10_percent") and not validation.get("degraded_or_compacted"):
264
+ print(f"::error::{validation.get('reason')}", file=sys.stderr)
265
+ return 1
266
append_record(args.usage_file, record)
267
print(json.dumps(record, indent=2))
268
return 0
tests/test_analyze_fallback.py
+33
@@ -229,6 +229,8 @@ class AnalyzeFallbackTests(unittest.TestCase):
229
report = json.loads(report_path.read_text(encoding="utf-8"))
230
self.assertEqual(exit_code, 0)
231
self.assertEqual(report["prompt_checksum_sha256"], analyze_fallback.checksum_text(rendered))
232
+ self.assertEqual(report["schema_version"], "analysis_input_manifest_v1")
233
+ self.assertEqual(report["rendered_prompt_estimate"]["tokens"], report["prompt_tokens"])
234
self.assertEqual(report["deterministic_slices"], ["new_repos", "trending_repos", "press_correlations", "prior_continuity"])
235
self.assertFalse(report["degraded"])
236
self.assertTrue(report["publish_eligible"])
@@ -242,6 +244,15 @@ class AnalyzeFallbackTests(unittest.TestCase):
244
self.assertEqual(inventories["raw_new_repos"]["repos"][0]["full_name"], "owner/new")
245
self.assertEqual(inventories["raw_trending_repos"]["repos"][0]["stars_gained"], 5)
246
self.assertGreater(inventories["prompt_new_repos"]["token_estimate"], 0)
247
+ slices = {item["name"]: item for item in report["generated_evidence_slices"]}
248
+ self.assertEqual(set(slices), {"new_repos", "trending_repos", "press_correlations", "prior_continuity"})
249
+ for slice_ref in slices.values():
250
+ self.assertTrue(slice_ref["path"].endswith(f"{slice_ref['checksum_sha256'][:12]}.json"))
251
+ self.assertFalse(slice_ref["validation_errors"])
252
+ self.assertTrue(Path(slice_ref["path"]).exists())
253
+ new_slice = json.loads(Path(slices["new_repos"]["path"]).read_text(encoding="utf-8"))
254
+ self.assertEqual(new_slice["records"][0]["full_name"], "owner/new")
255
+ self.assertIn("raw_json", new_slice["provenance"]["sources"])
256
257
def test_preflight_compacts_before_prompt_exceeds_budget(self) -> None:
258
tests_root = Path(__file__).resolve().parent
@@ -312,6 +323,28 @@ class AnalyzeFallbackTests(unittest.TestCase):
323
analyze_fallback.COMPACTED_TRENDING_REPOS_LIMIT,
324
)
325
326
+ def test_validate_evidence_slice_rejects_checksum_provenance_and_missing_fields(self) -> None:
327
+ payload = {
328
+ "schema_version": "analysis_evidence_slice_v1",
329
+ "slice_name": "new_repos",
330
+ "component": "new_repos",
331
+ "records": [{"full_name": "owner/repo"}],
332
+ "provenance": {"sources": {"raw_json": {"bytes": 10, "sha256": "abc"}}},
333
+ }
334
+ payload["checksum_sha256"] = analyze_fallback.checksum_payload(payload)
335
+ payload["records"][0]["full_name"] = "tampered/repo"
336
+
337
+ errors = analyze_fallback.validate_evidence_slice(payload, expected_checksum="different")
338
+
339
+ self.assertIn("slice checksum mismatch", errors)
340
+ self.assertIn("slice checksum does not match manifest reference", errors)
341
+ self.assertIn("record 0 missing url", errors)
342
+ self.assertIn("record 0 missing created_at", errors)
343
+
344
+ payload["provenance"] = {"sources": {}}
345
+ errors = analyze_fallback.validate_evidence_slice(payload)
346
+ self.assertIn("slice provenance sources missing", errors)
347
+
348
def test_extract_markdown_supports_message_parts(self) -> None:
349
payload = {
350
"choices": [
tests/test_map_reduce_dry_run.py
+4
@@ -63,6 +63,10 @@ def test_dry_run_emits_valid_contract_artifacts() -> None:
63
manifest = json.loads((output_dir / "manifest.json").read_text(encoding="utf-8"))
64
assert manifest["publish_eligible"] is False
65
assert manifest["candidate_only"] is True
66
+ rendered_estimate = manifest["component_estimates"]["rendered_prompt_estimate"]
67
+ assert set(rendered_estimate) == {"bytes", "tokens", "checksum_sha256"}
68
+ assert rendered_estimate["tokens"] > 0
69
+ assert rendered_estimate["checksum_sha256"]
70
for mapper in dry_run.MAPPER_IDS:
71
ledger = json.loads((output_dir / "maps" / f"{mapper}.json").read_text(encoding="utf-8"))
72
assert ledger["schema_version"] == "analysis_map_v1"
tests/test_render_press_context.py
+3
@@ -164,6 +164,9 @@ class TestRenderPressContext:
164
assert "1 repos have press correlation" in result
165
assert "AI Startup Raises $10M" in result
166
assert "acme/cool-project" in result
167
+ assert "articles_retained: 1" in result
168
+ assert "articles_dropped: 0" in result
169
+ assert "sources_failed: none" in result
170
171
def test_filters_low_relevance_articles(self):
172
low = _article(title="Irrelevant", relevance_score=0.2)
tests/test_track_token_usage.py
+134
@@ -86,6 +86,140 @@ class TrackTokenUsageTests(unittest.TestCase):
86
self.assertEqual(record["cost_usd"], 0.004)
87
self.assertFalse(record["estimated"])
88
89
+ def test_input_manifest_validation_fails_when_final_usage_differs_by_more_than_10_percent(self) -> None:
90
+ tests_root = Path(__file__).resolve().parent
91
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
92
+ base = Path(tmpdir)
93
+ usage_file = base / "token-usage.jsonl"
94
+ manifest = base / "analysis-input-manifest.json"
95
+ manifest.write_text(
96
+ json.dumps(
97
+ {
98
+ "schema_version": "analysis_input_manifest_v1",
99
+ "rendered_prompt_estimate": {"tokens": 1000, "bytes": 4000, "checksum_sha256": "abc"},
100
+ "prompt_within_budget": True,
101
+ "degraded": False,
102
+ }
103
+ ),
104
+ encoding="utf-8",
105
+ )
106
+
107
+ exit_code = track_token_usage.main(
108
+ [
109
+ "--stage",
110
+ "analysis",
111
+ "--source",
112
+ "copilot-cli",
113
+ "--model",
114
+ "copilot-default",
115
+ "--current-datetime",
116
+ "2026-05-19T08:00:00Z",
117
+ "--input-tokens",
118
+ "1200",
119
+ "--output-tokens",
120
+ "1",
121
+ "--input-manifest",
122
+ str(manifest),
123
+ "--usage-file",
124
+ str(usage_file),
125
+ ]
126
+ )
127
+
128
+ self.assertEqual(exit_code, 1)
129
+ self.assertFalse(usage_file.exists())
130
+
131
+ def test_input_manifest_validation_accepts_exact_10_percent_low_estimate_against_final_usage(self) -> None:
132
+ tests_root = Path(__file__).resolve().parent
133
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
134
+ base = Path(tmpdir)
135
+ usage_file = base / "token-usage.jsonl"
136
+ manifest = base / "analysis-input-manifest.json"
137
+ manifest.write_text(
138
+ json.dumps(
139
+ {
140
+ "schema_version": "analysis_input_manifest_v1",
141
+ "rendered_prompt_estimate": {"tokens": 900, "bytes": 3600, "checksum_sha256": "abc"},
142
+ "prompt_within_budget": True,
143
+ "degraded": False,
144
+ }
145
+ ),
146
+ encoding="utf-8",
147
+ )
148
+
149
+ exit_code = track_token_usage.main(
150
+ [
151
+ "--stage",
152
+ "analysis",
153
+ "--source",
154
+ "copilot-cli",
155
+ "--model",
156
+ "copilot-default",
157
+ "--current-datetime",
158
+ "2026-05-19T08:00:00Z",
159
+ "--input-tokens",
160
+ "1000",
161
+ "--output-tokens",
162
+ "1",
163
+ "--input-manifest",
164
+ str(manifest),
165
+ "--usage-file",
166
+ str(usage_file),
167
+ ]
168
+ )
169
+
170
+ self.assertEqual(exit_code, 0)
171
+ record = json.loads(usage_file.read_text(encoding="utf-8").strip())
172
+ validation = record["input_manifest_validation"]
173
+ self.assertTrue(validation["within_10_percent"])
174
+ self.assertEqual(validation["delta_ratio"], 0.1)
175
+
176
+ def test_input_manifest_validation_records_degraded_over_budget_compaction_reason(self) -> None:
177
+ tests_root = Path(__file__).resolve().parent
178
+ with tempfile.TemporaryDirectory(dir=tests_root) as tmpdir:
179
+ base = Path(tmpdir)
180
+ usage_file = base / "token-usage.jsonl"
181
+ manifest = base / "analysis-input-manifest.json"
182
+ manifest.write_text(
183
+ json.dumps(
184
+ {
185
+ "schema_version": "analysis_input_manifest_v1",
186
+ "prompt_tokens": 1000,
187
+ "prompt_within_budget": False,
188
+ "degraded": True,
189
+ "degradation_reason": "Prompt was deterministically compacted to fit the configured token budget.",
190
+ }
191
+ ),
192
+ encoding="utf-8",
193
+ )
194
+
195
+ exit_code = track_token_usage.main(
196
+ [
197
+ "--stage",
198
+ "analysis",
199
+ "--source",
200
+ "copilot-cli",
201
+ "--model",
202
+ "copilot-default",
203
+ "--current-datetime",
204
+ "2026-05-19T08:00:00Z",
205
+ "--input-tokens",
206
+ "1200",
207
+ "--output-tokens",
208
+ "1",
209
+ "--input-manifest",
210
+ str(manifest),
211
+ "--usage-file",
212
+ str(usage_file),
213
+ ]
214
+ )
215
+
216
+ self.assertEqual(exit_code, 0)
217
+ record = json.loads(usage_file.read_text(encoding="utf-8").strip())
218
+ validation = record["input_manifest_validation"]
219
+ self.assertFalse(validation["within_10_percent"])
220
+ self.assertTrue(validation["degraded_or_compacted"])
221
+ self.assertIn("Manifest is degraded/compacted", validation["reason"])
222
+
223
224
class ParseCopilotTranscriptTests(unittest.TestCase):
225
def test_parses_input_output_tokens_pattern(self) -> None: