Pointf5ive commited on
Commit
6083069
·
1 Parent(s): efebee3

Reject TOTEM scores that contradict fingerprint metrics

Browse files
Files changed (1) hide show
  1. src/totem_bridge.py +93 -7
src/totem_bridge.py CHANGED
@@ -138,6 +138,7 @@ def dashboard_output_contract() -> dict[str, Any]:
138
  field: {
139
  "score": "integer 0..100 matching scores.<field>",
140
  "fingerprint_metrics_used": ["VM metric ids used, e.g. VM-001, VM-010, VM-025"],
 
141
  "workbook_rule": "target/threshold/rubric rule used from workbook matrix",
142
  "manuscript_evidence": "short paraphrased manuscript evidence; no long excerpts",
143
  "reason": "why this score follows from the workbook rule and fingerprint metrics",
@@ -199,8 +200,11 @@ Use the workbook matrix as scoring authority, manuscript fingerprint VM metrics
199
  "Return exactly one JSON object matching required_output_contract. "
200
  "Scores must be 0..100 dashboard values for the six dimensions. "
201
  "Every score_evidence entry must cite at least one VM metric id from manuscript_fingerprint "
202
- "and one workbook rule/target from rubric_matrix. If the fingerprint is missing or insufficient, "
203
- "return gate REVISE and explain the missing evidence in dashboard_message. "
 
 
 
204
  "Do not write a report and do not include manuscript excerpts."
205
  ),
206
  }
@@ -210,7 +214,7 @@ Use the workbook matrix as scoring authority, manuscript fingerprint VM metrics
210
  if os.getenv("SPACE_ID") and not _env_truthy("TOTEM_ALLOW_FAKE_API_IN_SPACE"):
211
  raise BridgeError("TOTEM_FAKE_API is disabled on Hugging Face Spaces.")
212
  raw_payload = _fake_dashboard_payload(manuscript)
213
- validated = validate_dashboard_payload(raw_payload)
214
  raw_payload_json = json.dumps(raw_payload, ensure_ascii=False)
215
  audit_path = _write_token_audit(
216
  transaction_id=transaction_id,
@@ -319,7 +323,7 @@ Use the workbook matrix as scoring authority, manuscript fingerprint VM metrics
319
  raise BridgeValidationError(f"Model returned invalid JSON: {exc}") from exc
320
 
321
  try:
322
- validated = validate_dashboard_payload(parsed)
323
  except BridgeValidationError as exc:
324
  audit_path = _write_token_audit(
325
  transaction_id=transaction_id,
@@ -548,7 +552,7 @@ def _first_env_value(names: tuple[str, ...]) -> tuple[str | None, str | None]:
548
  return None, None
549
 
550
 
551
- def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
552
  if not isinstance(payload, dict):
553
  raise BridgeValidationError("Dashboard payload must be a JSON object.")
554
 
@@ -595,7 +599,8 @@ def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
595
  score_evidence = payload.get("score_evidence")
596
  if not isinstance(score_evidence, dict):
597
  raise BridgeValidationError("score_evidence must be a JSON object keyed by scoring dimension.")
598
- out["score_evidence"] = _normalize_score_evidence(score_evidence, out["scores"])
 
599
 
600
  rpq = payload.get("revision_priority_queue")
601
  if not isinstance(rpq, list):
@@ -609,7 +614,11 @@ def validate_dashboard_payload(payload: dict[str, Any]) -> dict[str, Any]:
609
  return out
610
 
611
 
612
- def _normalize_score_evidence(evidence: dict[str, Any], scores: dict[str, int]) -> dict[str, dict[str, Any]]:
 
 
 
 
613
  normalized: dict[str, dict[str, Any]] = {}
614
  missing: list[str] = []
615
  weak: list[str] = []
@@ -626,6 +635,10 @@ def _normalize_score_evidence(evidence: dict[str, Any], scores: dict[str, int])
626
  if not metric_ids or not any(m.upper().startswith("VM-") for m in metric_ids):
627
  weak.append(field)
628
 
 
 
 
 
629
  workbook_rule = str(item.get("workbook_rule") or "").strip()[:260]
630
  manuscript_evidence = str(item.get("manuscript_evidence") or "").strip()[:260]
631
  reason = str(item.get("reason") or "").strip()[:320]
@@ -635,6 +648,10 @@ def _normalize_score_evidence(evidence: dict[str, Any], scores: dict[str, int])
635
  normalized[field] = {
636
  "score": scores[field],
637
  "fingerprint_metrics_used": metric_ids[:8],
 
 
 
 
638
  "workbook_rule": workbook_rule,
639
  "manuscript_evidence": manuscript_evidence,
640
  "reason": reason,
@@ -648,6 +665,55 @@ def _normalize_score_evidence(evidence: dict[str, Any], scores: dict[str, int])
648
  return normalized
649
 
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> str:
652
  weights = rubric_matrix.get("weights", {}) if isinstance(rubric_matrix, dict) else {}
653
  if not isinstance(weights, dict) or not weights:
@@ -802,3 +868,23 @@ def _fingerprint_metric_count(fingerprint: dict[str, Any] | None) -> int:
802
  if not isinstance(fingerprint, dict):
803
  return 0
804
  return sum(1 for key in fingerprint if str(key).upper().startswith("VM-"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  field: {
139
  "score": "integer 0..100 matching scores.<field>",
140
  "fingerprint_metrics_used": ["VM metric ids used, e.g. VM-001, VM-010, VM-025"],
141
+ "fingerprint_metric_values": {"VM-010": "numeric/string value used from manuscript_fingerprint"},
142
  "workbook_rule": "target/threshold/rubric rule used from workbook matrix",
143
  "manuscript_evidence": "short paraphrased manuscript evidence; no long excerpts",
144
  "reason": "why this score follows from the workbook rule and fingerprint metrics",
 
200
  "Return exactly one JSON object matching required_output_contract. "
201
  "Scores must be 0..100 dashboard values for the six dimensions. "
202
  "Every score_evidence entry must cite at least one VM metric id from manuscript_fingerprint "
203
+ "with its observed value in fingerprint_metric_values, plus one workbook rule/target from rubric_matrix. "
204
+ "If the fingerprint is missing or insufficient, return gate REVISE and explain the missing evidence in dashboard_message. "
205
+ "Hard caps for picture-book scoring: if VM-010 sentence length mean is above 30 or VM-025 reading age "
206
+ "is above 10, clarity and read_aloud_flow must not exceed 75. If VM-025 reading age is above 10, "
207
+ "commercial_viability must not exceed 75. If VM-002 syllable variance is above 5, rhythm must not exceed 75. "
208
  "Do not write a report and do not include manuscript excerpts."
209
  ),
210
  }
 
214
  if os.getenv("SPACE_ID") and not _env_truthy("TOTEM_ALLOW_FAKE_API_IN_SPACE"):
215
  raise BridgeError("TOTEM_FAKE_API is disabled on Hugging Face Spaces.")
216
  raw_payload = _fake_dashboard_payload(manuscript)
217
+ validated = validate_dashboard_payload(raw_payload, manuscript.fingerprint)
218
  raw_payload_json = json.dumps(raw_payload, ensure_ascii=False)
219
  audit_path = _write_token_audit(
220
  transaction_id=transaction_id,
 
323
  raise BridgeValidationError(f"Model returned invalid JSON: {exc}") from exc
324
 
325
  try:
326
+ validated = validate_dashboard_payload(parsed, manuscript.fingerprint)
327
  except BridgeValidationError as exc:
328
  audit_path = _write_token_audit(
329
  transaction_id=transaction_id,
 
552
  return None, None
553
 
554
 
555
+ def validate_dashboard_payload(payload: dict[str, Any], fingerprint: dict[str, Any] | None = None) -> dict[str, Any]:
556
  if not isinstance(payload, dict):
557
  raise BridgeValidationError("Dashboard payload must be a JSON object.")
558
 
 
599
  score_evidence = payload.get("score_evidence")
600
  if not isinstance(score_evidence, dict):
601
  raise BridgeValidationError("score_evidence must be a JSON object keyed by scoring dimension.")
602
+ out["score_evidence"] = _normalize_score_evidence(score_evidence, out["scores"], fingerprint)
603
+ _validate_fingerprint_score_alignment(out, fingerprint)
604
 
605
  rpq = payload.get("revision_priority_queue")
606
  if not isinstance(rpq, list):
 
614
  return out
615
 
616
 
617
+ def _normalize_score_evidence(
618
+ evidence: dict[str, Any],
619
+ scores: dict[str, int],
620
+ fingerprint: dict[str, Any] | None = None,
621
+ ) -> dict[str, dict[str, Any]]:
622
  normalized: dict[str, dict[str, Any]] = {}
623
  missing: list[str] = []
624
  weak: list[str] = []
 
635
  if not metric_ids or not any(m.upper().startswith("VM-") for m in metric_ids):
636
  weak.append(field)
637
 
638
+ required_metric = _required_bottleneck_metric(field, fingerprint)
639
+ if required_metric and required_metric not in {m.upper().split("_", 1)[0] for m in metric_ids}:
640
+ weak.append(field)
641
+
642
  workbook_rule = str(item.get("workbook_rule") or "").strip()[:260]
643
  manuscript_evidence = str(item.get("manuscript_evidence") or "").strip()[:260]
644
  reason = str(item.get("reason") or "").strip()[:320]
 
648
  normalized[field] = {
649
  "score": scores[field],
650
  "fingerprint_metrics_used": metric_ids[:8],
651
+ "fingerprint_metric_values": {
652
+ metric_id.upper().split("_", 1)[0]: _fingerprint_value(fingerprint, metric_id)
653
+ for metric_id in metric_ids[:8]
654
+ },
655
  "workbook_rule": workbook_rule,
656
  "manuscript_evidence": manuscript_evidence,
657
  "reason": reason,
 
665
  return normalized
666
 
667
 
668
+ def _required_bottleneck_metric(field: str, fingerprint: dict[str, Any] | None) -> str | None:
669
+ sentence_mean = _fingerprint_float(fingerprint, "VM-010")
670
+ reading_age = _fingerprint_float(fingerprint, "VM-025")
671
+ syllable_variance = _fingerprint_float(fingerprint, "VM-002")
672
+
673
+ if field == "rhythm" and syllable_variance is not None and syllable_variance > 5:
674
+ return "VM-002"
675
+ if field in {"clarity", "read_aloud_flow"}:
676
+ if sentence_mean is not None and sentence_mean > 30:
677
+ return "VM-010"
678
+ if reading_age is not None and reading_age > 10:
679
+ return "VM-025"
680
+ if field == "commercial_viability" and reading_age is not None and reading_age > 10:
681
+ return "VM-025"
682
+ return None
683
+
684
+
685
+ def _validate_fingerprint_score_alignment(out: dict[str, Any], fingerprint: dict[str, Any] | None) -> None:
686
+ if not isinstance(fingerprint, dict) or not fingerprint:
687
+ return
688
+
689
+ sentence_mean = _fingerprint_float(fingerprint, "VM-010")
690
+ reading_age = _fingerprint_float(fingerprint, "VM-025")
691
+ syllable_variance = _fingerprint_float(fingerprint, "VM-002")
692
+ problems: list[str] = []
693
+
694
+ if sentence_mean is not None and sentence_mean > 30:
695
+ if out["scores"].get("clarity", 0) > 75:
696
+ problems.append(f"clarity={out['scores']['clarity']} exceeds cap 75 while VM-010 sentence mean is {sentence_mean}")
697
+ if out["scores"].get("read_aloud_flow", 0) > 75:
698
+ problems.append(
699
+ f"read_aloud_flow={out['scores']['read_aloud_flow']} exceeds cap 75 while VM-010 sentence mean is {sentence_mean}"
700
+ )
701
+ if reading_age is not None and reading_age > 10:
702
+ if out["scores"].get("read_aloud_flow", 0) > 75:
703
+ problems.append(
704
+ f"read_aloud_flow={out['scores']['read_aloud_flow']} exceeds cap 75 while VM-025 reading age is {reading_age}"
705
+ )
706
+ if out["scores"].get("commercial_viability", 0) > 75:
707
+ problems.append(
708
+ f"commercial_viability={out['scores']['commercial_viability']} exceeds cap 75 while VM-025 reading age is {reading_age}"
709
+ )
710
+ if syllable_variance is not None and syllable_variance > 5 and out["scores"].get("rhythm", 0) > 75:
711
+ problems.append(f"rhythm={out['scores']['rhythm']} exceeds cap 75 while VM-002 syllable variance is {syllable_variance}")
712
+
713
+ if problems:
714
+ raise BridgeValidationError("Fingerprint/score contradiction: " + "; ".join(problems))
715
+
716
+
717
  def recompute_gate(metrics: dict[str, int], rubric_matrix: dict[str, Any]) -> str:
718
  weights = rubric_matrix.get("weights", {}) if isinstance(rubric_matrix, dict) else {}
719
  if not isinstance(weights, dict) or not weights:
 
868
  if not isinstance(fingerprint, dict):
869
  return 0
870
  return sum(1 for key in fingerprint if str(key).upper().startswith("VM-"))
871
+
872
+
873
+ def _fingerprint_value(fingerprint: dict[str, Any] | None, metric_id: str) -> Any:
874
+ if not isinstance(fingerprint, dict):
875
+ return None
876
+ wanted = str(metric_id).upper().split("_", 1)[0]
877
+ for key, value in fingerprint.items():
878
+ if str(key).upper().startswith(wanted):
879
+ return value
880
+ return None
881
+
882
+
883
+ def _fingerprint_float(fingerprint: dict[str, Any] | None, metric_id: str) -> float | None:
884
+ value = _fingerprint_value(fingerprint, metric_id)
885
+ try:
886
+ if value is None or value == "":
887
+ return None
888
+ return float(value)
889
+ except Exception:
890
+ return None