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: