13
import json
14
import re
15
import sys
16
+import time
17
from dataclasses import asdict, dataclass
18
from datetime import UTC, datetime
19
from pathlib import Path
22
try:
23
from scripts.analyze_fallback import find_previous_summary
24
from scripts.analysis_gate import validate_analysis, validate_publish_quality
25
+ from scripts.model_pricing import estimate_cost_usd
26
+ from scripts.observability_metrics import (
27
+ DEFAULT_OBSERVABILITY_DIR,
28
+ AnalysisMetrics,
29
+ METRICS_SCHEMA_VERSION,
30
+ MapReduceMetrics,
31
+ ObservabilityLedger,
32
+ emit_ledger,
33
+ )
34
from scripts.render_press_context import estimate_tokens
35
from scripts.sanitize_repo_content import sanitize_repo_payload
36
except ModuleNotFoundError: # pragma: no cover - script execution path
37
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
38
from scripts.analyze_fallback import find_previous_summary
39
from scripts.analysis_gate import validate_analysis, validate_publish_quality
40
+ from scripts.model_pricing import estimate_cost_usd
41
+ from scripts.observability_metrics import (
42
+ DEFAULT_OBSERVABILITY_DIR,
43
+ AnalysisMetrics,
44
+ METRICS_SCHEMA_VERSION,
45
+ MapReduceMetrics,
46
+ ObservabilityLedger,
47
+ emit_ledger,
48
+ )
49
from scripts.render_press_context import estimate_tokens
50
from scripts.sanitize_repo_content import sanitize_repo_payload
51
107
path.write_text(stable_json(payload), encoding="utf-8")
108
109
110
+def metric_cost_usd(model: str, input_tokens: int, output_tokens: int) -> float:
111
+ return estimate_cost_usd(model, input_tokens, output_tokens) or 0.0
112
+
113
+
114
def file_ref(path: Path | None) -> ArtifactRef | None:
115
if path is None or not path.exists() or not path.is_file():
116
return None
118
return ArtifactRef(path=path.as_posix(), sha256=sha256_bytes(data), bytes=len(data))
119
120
121
+def collect_gate_failure_reasons(qa_report: dict[str, Any]) -> list[str]:
122
+ reasons: list[str] = []
123
+ checks = qa_report.get("checks", {}) if isinstance(qa_report.get("checks"), dict) else {}
124
+ mapper_contracts = checks.get("mapper_contracts", {})
125
+ if isinstance(mapper_contracts, dict):
126
+ errors_by_mapper = mapper_contracts.get("errors_by_mapper", {})
127
+ if isinstance(errors_by_mapper, dict):
128
+ for mapper, errors in sorted(errors_by_mapper.items()):
129
+ if isinstance(errors, list):
130
+ reasons.extend(f"{mapper}: {error}" for error in errors)
131
+ for key in ("structural_analysis_gate", "evidence_and_editorial_gates", "publish_provenance_gate"):
132
+ check = checks.get(key, {})
133
+ if isinstance(check, dict) and check.get("expected_failure") is not True and isinstance(check.get("errors"), list):
134
+ reasons.extend(str(error) for error in check["errors"] if error)
135
+ if isinstance(qa_report.get("regressions"), list):
136
+ reasons.extend(str(error) for error in qa_report["regressions"] if error)
137
+ sidecars = checks.get("sidecars_present", {})
138
+ if isinstance(sidecars, dict) and sidecars.get("passed") is False:
139
+ reasons.append("sidecars_present failed")
140
+ seen: set[str] = set()
141
+ ordered: list[str] = []
142
+ for reason in reasons:
143
+ normalized = reason.strip()
144
+ if normalized and normalized not in seen:
145
+ seen.add(normalized)
146
+ ordered.append(normalized)
147
+ return ordered
148
+
149
+
150
def normalize_repo_name(repo: dict[str, Any]) -> str:
151
full_name = str(repo.get("full_name") or "").strip()
152
if full_name:
765
766
767
def run(args: argparse.Namespace) -> dict[str, Path]:
768
+ analysis_started = time.monotonic()
769
raw_payload = sanitize_repo_payload(load_json(args.raw_json))
770
week = raw_payload["week"]
771
raw_ref = file_ref(args.raw_json)
775
previous_summary = find_previous_summary(week, args.analyzed_dir)
776
previous_ref = file_ref(previous_summary)
777
725
- maps = {
726
- "new_repos": map_repositories(
778
+ map_stage_metrics: list[MapReduceMetrics] = []
779
+ maps: dict[str, dict[str, Any]] = {}
780
+ map_builders = {
781
+ "new_repos": lambda: map_repositories(
782
run_id=args.run_id,
783
week=week,
784
raw_path=args.raw_json,
788
mode="new",
789
max_repos=args.max_repos_per_ledger,
790
),
736
- "trending_repos": map_repositories(
791
+ "trending_repos": lambda: map_repositories(
792
run_id=args.run_id,
793
week=week,
794
raw_path=args.raw_json,
798
mode="trending",
799
max_repos=args.max_repos_per_ledger,
800
),
746
- "press_correlations": map_press(run_id=args.run_id, week=week, press_path=args.press_context, press_ref=press_ref, raw_ref=raw_ref),
747
- "prior_continuity": map_prior(run_id=args.run_id, week=week, previous_summary=previous_summary, previous_ref=previous_ref, raw_ref=raw_ref),
801
+ "press_correlations": lambda: map_press(
802
+ run_id=args.run_id,
803
+ week=week,
804
+ press_path=args.press_context,
805
+ press_ref=press_ref,
806
+ raw_ref=raw_ref,
807
+ ),
808
+ "prior_continuity": lambda: map_prior(
809
+ run_id=args.run_id,
810
+ week=week,
811
+ previous_summary=previous_summary,
812
+ previous_ref=previous_ref,
813
+ raw_ref=raw_ref,
814
+ ),
815
}
816
+ for name in MAPPER_IDS:
817
+ stage_started = time.monotonic()
818
+ payload = map_builders[name]()
819
+ maps[name] = payload
820
+ stage_duration = round(time.monotonic() - stage_started, 3)
821
+ input_tokens = int(payload.get("slice", {}).get("input_token_estimate") or 0)
822
+ output_tokens = int(payload.get("token_estimate") or 0)
823
+ map_stage_metrics.append(
824
+ MapReduceMetrics(
825
+ stage=name,
826
+ duration_seconds=stage_duration,
827
+ input_tokens=input_tokens,
828
+ output_tokens=output_tokens,
829
+ cost_usd=metric_cost_usd(args.analysis_model, input_tokens, output_tokens),
830
+ status="pass",
831
+ gate_failure_reasons=[],
832
+ )
833
+ )
834
map_errors = {name: validate_map(payload) for name, payload in maps.items()}
835
if any(map_errors.values()):
836
for name, errors in map_errors.items():
837
if errors:
838
maps[name]["status"] = "failed"
839
maps[name]["errors"] = errors
840
+ map_metrics_by_stage = {metric.stage: metric for metric in map_stage_metrics}
841
+ for name, errors in map_errors.items():
842
+ if errors:
843
+ metric = map_metrics_by_stage.get(name)
844
+ if metric is not None:
845
+ map_stage_metrics[map_stage_metrics.index(metric)] = MapReduceMetrics(
846
+ stage=metric.stage,
847
+ duration_seconds=metric.duration_seconds,
848
+ input_tokens=metric.input_tokens,
849
+ output_tokens=metric.output_tokens,
850
+ cost_usd=metric.cost_usd,
851
+ status="fail",
852
+ gate_failure_reasons=list(errors),
853
+ )
854
+ reduce_started = time.monotonic()
855
plan, rejected, contradictions = reduce_ledgers(list(maps.values()), raw_payload=raw_payload)
856
candidate_text = render_candidate(plan, raw_payload, args.current_datetime)
857
885
source=args.analysis_source,
886
model=args.analysis_model,
887
)
888
+ reduce_duration = round(time.monotonic() - reduce_started, 3)
889
+ reduce_input_tokens = sum(metric.output_tokens for metric in map_stage_metrics)
890
+ reduce_output_tokens = estimate_tokens(candidate_text)
891
+ reduce_failure_reasons = collect_gate_failure_reasons(qa)
892
+ reduce_stage_metric = MapReduceMetrics(
893
+ stage="reduce",
894
+ duration_seconds=reduce_duration,
895
+ input_tokens=reduce_input_tokens,
896
+ output_tokens=reduce_output_tokens,
897
+ cost_usd=metric_cost_usd(args.analysis_model, reduce_input_tokens, reduce_output_tokens),
898
+ status="pass" if qa.get("status") == "passed" else "fail",
899
+ gate_failure_reasons=reduce_failure_reasons,
900
+ )
901
write_json(out / "qa-comparison-report.json", qa)
902
raw_component = file_ref(args.raw_json)
903
press_component = file_ref(args.press_context)
955
"promotion_policy": "blocked: dry-run/candidate-only map/reduce output must not write data/analyzed, content/weekly, deploy, notify, or satisfy publish eligibility.",
956
}
957
write_json(out / "manifest.json", manifest)
958
+ total_input_tokens = sum(metric.input_tokens for metric in map_stage_metrics) + reduce_stage_metric.input_tokens
959
+ total_output_tokens = sum(metric.output_tokens for metric in map_stage_metrics) + reduce_stage_metric.output_tokens
960
+ total_cost_usd = round(sum(metric.cost_usd for metric in map_stage_metrics) + reduce_stage_metric.cost_usd, 6)
961
+ analysis_duration = round(time.monotonic() - analysis_started, 3)
962
+ observability_path = DEFAULT_OBSERVABILITY_DIR / f"{week}-map-reduce.json"
963
+ emit_ledger(
964
+ ObservabilityLedger(
965
+ schema_version=METRICS_SCHEMA_VERSION,
966
+ run_id=args.run_id,
967
+ week=week,
968
+ timestamp=args.current_datetime,
969
+ crawl_metrics=[],
970
+ analysis_metrics=AnalysisMetrics(
971
+ duration_seconds=analysis_duration,
972
+ token_ledger={
973
+ "input_tokens": total_input_tokens,
974
+ "output_tokens": total_output_tokens,
975
+ "total_tokens": total_input_tokens + total_output_tokens,
976
+ "cost_usd": total_cost_usd,
977
+ },
978
+ map_stages=map_stage_metrics,
979
+ reduce_stage=reduce_stage_metric,
980
+ ),
981
+ environment={
982
+ "pipeline": "map-reduce-dry-run",
983
+ "analysis_source": args.analysis_source,
984
+ "analysis_model": args.analysis_model,
985
+ "output_dir": out.as_posix(),
986
+ "qa_status": qa.get("status"),
987
+ "publish_eligible": qa.get("publish_eligible"),
988
+ "pass_fail_counts": {
989
+ "map_pass": sum(1 for metric in map_stage_metrics if metric.status == "pass"),
990
+ "map_fail": sum(1 for metric in map_stage_metrics if metric.status == "fail"),
991
+ "reduce_pass": 1 if reduce_stage_metric.status == "pass" else 0,
992
+ "reduce_fail": 1 if reduce_stage_metric.status == "fail" else 0,
993
+ },
994
+ "gate_failure_reasons": reduce_failure_reasons,
995
+ "artifacts": {
996
+ "manifest": (out / "manifest.json").as_posix(),
997
+ "candidate": candidate_path.as_posix(),
998
+ "qa_report": (out / "qa-comparison-report.json").as_posix(),
999
+ },
1000
+ },
1001
+ ),
1002
+ observability_path,
1003
+ )
1004
return {
1005
"manifest": out / "manifest.json",
1006
"qa_report": out / "qa-comparison-report.json",
1007
"candidate": candidate_path,
1008
+ "observability": observability_path,
1009
}
1010
1011