vn6295337 Claude Opus 4.5 commited on
Commit
a2c9702
·
1 Parent(s): 484a7a7

Layer 4: Add deterministic numeric validation in Critic

Browse files

Enforces machine-verifiable numeric accuracy:

1. Prompt changes (analyzer.py):
- Require [M##] citations for all metric values
- Example: "Revenue of $394.3B [M01] demonstrates..."
- Clear warning that citations are auto-verified

2. New validator (numeric_validator.py):
- Extract [M##] citations from SWOT output
- Normalize values ($394.3B -> 394300000000)
- Compare against metric_reference with tolerance
- Return specific mismatch descriptions

3. Critic integration (critic.py):
- Validate citations after LLM evaluation
- If mismatches: cap evidence_grounding at 4, force rejection
- Add specific feedback for revision
- Log validation results to activity log

Tested with Ford hallucination case:
- Detects: market_cap $43.4B vs expected $56.6B
- Detects: pe_trailing 21.3 vs expected 12.14

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

src/nodes/analyzer.py CHANGED
@@ -1058,6 +1058,7 @@ Weighted Score: {critique_details.get('weighted_score', 0):.1f} / 10
1058
  - Apply each point in "Actionable Feedback" — these are specific instructions
1059
  - Keep everything listed under "Strengths to Preserve" — do not modify these sections
1060
  - **Use EXACT metric values from the METRIC REFERENCE TABLE** — copy numbers verbatim
 
1061
  - Include the 'as of' date when citing temporal metrics
1062
  {ev_note}
1063
 
@@ -1065,6 +1066,7 @@ Weighted Score: {critique_details.get('weighted_score', 0):.1f} / 10
1065
  - Ignore lower-priority feedback items — address all of them
1066
  - Introduce new metrics not in the original input data
1067
  - **Round, estimate, or approximate any numbers** — use exact values only
 
1068
  - Remove content that was working well
1069
  - Add defensive caveats or apologies about the revision
1070
  - Reference the revision process in your output — produce a clean SWOT as if first attempt
@@ -1132,26 +1134,26 @@ Produce a SWOT analysis with this exact structure:
1132
 
1133
  ## Strengths
1134
  For each (3-5 points):
1135
- - **Finding:** [One sentence with specific metric from the METRIC REFERENCE TABLE]
1136
  - **Strategic Implication:** [Why this matters]
1137
  - **Durability:** [High/Medium/Low]
1138
 
1139
  ## Weaknesses
1140
  For each (3-5 points):
1141
- - **Finding:** [One sentence with specific metric from the METRIC REFERENCE TABLE]
1142
  - **Severity:** [Critical/Moderate/Minor]
1143
  - **Trend:** [Improving/Stable/Deteriorating]
1144
  - **Remediation Levers:** [What could improve this]
1145
 
1146
  ## Opportunities
1147
  For each (3-5 points):
1148
- - **Catalyst:** [Description with supporting data]
1149
  - **Timing:** [Near-term/Medium-term/Long-term]
1150
  - **Execution Requirements:** [What must happen]
1151
 
1152
  ## Threats
1153
  For each (3-5 points):
1154
- - **Risk Factor:** [Description with supporting data]
1155
  - **Probability:** [High/Medium/Low]
1156
  - **Impact:** [Potential magnitude]
1157
  - **Mitigation Options:** [Possible responses]
@@ -1161,7 +1163,11 @@ For each (3-5 points):
1161
  - **Data Gaps:** [Any unavailable metrics]
1162
  - **Confidence Level:** [High/Medium/Low]
1163
 
1164
- CRITICAL: Every numeric finding MUST use the EXACT value from the METRIC REFERENCE TABLE above. Do NOT round or estimate."""
 
 
 
 
1165
 
1166
  return prompt, metric_lookup, ref_hash
1167
 
 
1058
  - Apply each point in "Actionable Feedback" — these are specific instructions
1059
  - Keep everything listed under "Strengths to Preserve" — do not modify these sections
1060
  - **Use EXACT metric values from the METRIC REFERENCE TABLE** — copy numbers verbatim
1061
+ - **Include [M##] citation after every metric value** — e.g., "$394.3B [M01]"
1062
  - Include the 'as of' date when citing temporal metrics
1063
  {ev_note}
1064
 
 
1066
  - Ignore lower-priority feedback items — address all of them
1067
  - Introduce new metrics not in the original input data
1068
  - **Round, estimate, or approximate any numbers** — use exact values only
1069
+ - **Omit [M##] citations** — they are required for automatic verification
1070
  - Remove content that was working well
1071
  - Add defensive caveats or apologies about the revision
1072
  - Reference the revision process in your output — produce a clean SWOT as if first attempt
 
1134
 
1135
  ## Strengths
1136
  For each (3-5 points):
1137
+ - **Finding:** [One sentence with metric value and citation, e.g., "Revenue of $394.3B [M01] shows..."]
1138
  - **Strategic Implication:** [Why this matters]
1139
  - **Durability:** [High/Medium/Low]
1140
 
1141
  ## Weaknesses
1142
  For each (3-5 points):
1143
+ - **Finding:** [One sentence with metric value and citation, e.g., "Debt/equity of 1.87 [M04] indicates..."]
1144
  - **Severity:** [Critical/Moderate/Minor]
1145
  - **Trend:** [Improving/Stable/Deteriorating]
1146
  - **Remediation Levers:** [What could improve this]
1147
 
1148
  ## Opportunities
1149
  For each (3-5 points):
1150
+ - **Catalyst:** [Description with metric citations where applicable]
1151
  - **Timing:** [Near-term/Medium-term/Long-term]
1152
  - **Execution Requirements:** [What must happen]
1153
 
1154
  ## Threats
1155
  For each (3-5 points):
1156
+ - **Risk Factor:** [Description with metric citations where applicable]
1157
  - **Probability:** [High/Medium/Low]
1158
  - **Impact:** [Potential magnitude]
1159
  - **Mitigation Options:** [Possible responses]
 
1163
  - **Data Gaps:** [Any unavailable metrics]
1164
  - **Confidence Level:** [High/Medium/Low]
1165
 
1166
+ CRITICAL CITATION REQUIREMENTS:
1167
+ 1. Every numeric finding MUST include the reference ID in brackets: value [M##]
1168
+ 2. Use EXACT values from the METRIC REFERENCE TABLE - do NOT round or estimate
1169
+ 3. Example: "Revenue of $394,328,000,000 [M01] demonstrates strong market position"
1170
+ 4. Citations will be automatically verified - mismatches cause rejection"""
1171
 
1172
  return prompt, metric_lookup, ref_hash
1173
 
src/nodes/critic.py CHANGED
@@ -3,6 +3,10 @@ from langsmith import traceable
3
  import json
4
  import time
5
 
 
 
 
 
6
 
7
  def _add_activity_log(workflow_id, progress_store, step, message):
8
  """Helper to add activity log entry."""
@@ -353,6 +357,55 @@ def critic_node(state, workflow_id=None, progress_store=None):
353
  weighted_score = result["weighted_score"]
354
  scores = result["scores"]
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  # Handle ESCALATE if max iterations reached
357
  if iteration > 3 and status == "REJECTED":
358
  status = "ESCALATE"
 
3
  import json
4
  import time
5
 
6
+ # Layer 4: Deterministic numeric validation
7
+ from src.utils.numeric_validator import validate_numeric_accuracy
8
+ from src.nodes.analyzer import _verify_reference_integrity
9
+
10
 
11
  def _add_activity_log(workflow_id, progress_store, step, message):
12
  """Helper to add activity log entry."""
 
357
  weighted_score = result["weighted_score"]
358
  scores = result["scores"]
359
 
360
+ # ============================================================
361
+ # LAYER 4: Deterministic Numeric Validation
362
+ # ============================================================
363
+ metric_ref = state.get("metric_reference", {})
364
+ ref_hash = state.get("metric_reference_hash", "")
365
+
366
+ if metric_ref and ref_hash:
367
+ # Verify integrity before using
368
+ if _verify_reference_integrity(metric_ref, ref_hash):
369
+ mismatches = validate_numeric_accuracy(report, metric_ref)
370
+ if mismatches:
371
+ _add_activity_log(workflow_id, progress_store, "critic",
372
+ f"Numeric validation: {len(mismatches)} mismatch(es) detected")
373
+
374
+ # Ensure hallucinations_detected exists
375
+ if "hallucinations_detected" not in result:
376
+ result["hallucinations_detected"] = []
377
+ result["hallucinations_detected"].extend(mismatches)
378
+
379
+ # Cap evidence_grounding score
380
+ if scores.get("evidence_grounding", 0) > 4:
381
+ scores["evidence_grounding"] = 4
382
+ if "hard_floor_violations" not in result:
383
+ result["hard_floor_violations"] = []
384
+ result["hard_floor_violations"].append(
385
+ "Numeric mismatch detected - evidence_grounding capped at 4"
386
+ )
387
+
388
+ # Add specific feedback
389
+ if "actionable_feedback" not in result:
390
+ result["actionable_feedback"] = []
391
+ result["actionable_feedback"].insert(0,
392
+ f"Fix {len(mismatches)} numeric mismatch(es) - use exact values with [M##] citations from reference table"
393
+ )
394
+
395
+ # Recalculate weighted score with capped evidence_grounding
396
+ weighted_score = calculate_weighted_score(scores)
397
+ result["weighted_score"] = weighted_score
398
+
399
+ # Force rejection if numeric mismatches
400
+ status = "REJECTED"
401
+ result["status"] = status
402
+ else:
403
+ _add_activity_log(workflow_id, progress_store, "critic",
404
+ "Numeric validation: all citations verified")
405
+ else:
406
+ _add_activity_log(workflow_id, progress_store, "critic",
407
+ "Warning: metric reference integrity check failed - skipping numeric validation")
408
+
409
  # Handle ESCALATE if max iterations reached
410
  if iteration > 3 and status == "REJECTED":
411
  status = "ESCALATE"
src/utils/numeric_validator.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic numeric validation for SWOT analysis outputs.
3
+
4
+ Layer 4: Validates that cited metric values match the reference table.
5
+ Extracts [M##] citations from SWOT text and verifies against metric_reference dict.
6
+ """
7
+
8
+ import re
9
+ from typing import Optional
10
+
11
+
12
+ # Pattern to match citations like: $394.3B [M01], 25.3% [M02], 32.5 [M04]
13
+ CITATION_PATTERN = re.compile(
14
+ r'([\d,$\.]+[BMK%]?)\s*\[M(\d{2})\]',
15
+ re.IGNORECASE
16
+ )
17
+
18
+
19
+ def normalize_value(text: str) -> Optional[float]:
20
+ """
21
+ Normalize a value string to a float for comparison.
22
+
23
+ Handles:
24
+ - Currency: $394.3B -> 394300000000, $56.6M -> 56600000
25
+ - Percentages: 25.3% -> 25.3
26
+ - Plain numbers: 32.5 -> 32.5, 1,234 -> 1234
27
+
28
+ Returns None if parsing fails.
29
+ """
30
+ if not text:
31
+ return None
32
+
33
+ # Remove whitespace and common formatting
34
+ text = text.strip().replace(',', '').replace(' ', '')
35
+
36
+ # Handle currency with B/M/K suffix
37
+ if text.startswith('$'):
38
+ text = text[1:] # Remove $
39
+ multiplier = 1
40
+ if text.upper().endswith('B'):
41
+ multiplier = 1e9
42
+ text = text[:-1]
43
+ elif text.upper().endswith('M'):
44
+ multiplier = 1e6
45
+ text = text[:-1]
46
+ elif text.upper().endswith('K'):
47
+ multiplier = 1e3
48
+ text = text[:-1]
49
+ try:
50
+ return float(text) * multiplier
51
+ except ValueError:
52
+ return None
53
+
54
+ # Handle percentages
55
+ if text.endswith('%'):
56
+ try:
57
+ return float(text[:-1])
58
+ except ValueError:
59
+ return None
60
+
61
+ # Plain number
62
+ try:
63
+ return float(text)
64
+ except ValueError:
65
+ return None
66
+
67
+
68
+ def values_match(found_value: float, expected_value: float, value_type: str = "unknown") -> bool:
69
+ """
70
+ Check if two values match within acceptable tolerance.
71
+
72
+ Tolerances:
73
+ - Currency (large numbers): ±1% relative
74
+ - Percentages: ±0.1 absolute
75
+ - Small decimals (ratios, etc.): ±0.05 absolute
76
+ """
77
+ if found_value is None or expected_value is None:
78
+ return False
79
+
80
+ # Large numbers (currency) - use relative tolerance
81
+ if abs(expected_value) >= 1e6:
82
+ tolerance = abs(expected_value) * 0.01 # 1%
83
+ return abs(found_value - expected_value) <= tolerance
84
+
85
+ # Small numbers - use absolute tolerance
86
+ # Percentages and ratios
87
+ if abs(expected_value) < 100:
88
+ tolerance = 0.15 # Allow slight rounding differences
89
+ return abs(found_value - expected_value) <= tolerance
90
+
91
+ # Medium numbers
92
+ tolerance = abs(expected_value) * 0.01
93
+ return abs(found_value - expected_value) <= tolerance
94
+
95
+
96
+ def extract_citations(text: str) -> list[dict]:
97
+ """
98
+ Extract all [M##] citations from text.
99
+
100
+ Returns list of dicts:
101
+ [
102
+ {"ref_id": "M01", "cited_value": "$394.3B", "normalized": 394300000000.0},
103
+ {"ref_id": "M02", "cited_value": "25.3%", "normalized": 25.3},
104
+ ]
105
+ """
106
+ citations = []
107
+ for match in CITATION_PATTERN.finditer(text):
108
+ cited_value = match.group(1)
109
+ ref_num = match.group(2)
110
+ ref_id = f"M{ref_num}"
111
+ normalized = normalize_value(cited_value)
112
+ citations.append({
113
+ "ref_id": ref_id,
114
+ "cited_value": cited_value,
115
+ "normalized": normalized
116
+ })
117
+ return citations
118
+
119
+
120
+ def validate_citations(swot_text: str, metric_reference: dict) -> dict:
121
+ """
122
+ Validate all citations in SWOT text against metric_reference.
123
+
124
+ Args:
125
+ swot_text: The SWOT analysis output
126
+ metric_reference: Dict from Layer 1 with format:
127
+ {"M01": {"key": "revenue", "raw_value": 394328000000, "formatted": "..."}, ...}
128
+
129
+ Returns:
130
+ {
131
+ "valid": bool,
132
+ "citations_found": int,
133
+ "mismatches": [
134
+ "revenue [M01]: cited $56.6B, expected $394.3B",
135
+ ...
136
+ ],
137
+ "missing_refs": ["M99"], # Citations to non-existent refs
138
+ "details": [...] # Full details for each citation
139
+ }
140
+ """
141
+ citations = extract_citations(swot_text)
142
+
143
+ result = {
144
+ "valid": True,
145
+ "citations_found": len(citations),
146
+ "mismatches": [],
147
+ "missing_refs": [],
148
+ "details": []
149
+ }
150
+
151
+ for citation in citations:
152
+ ref_id = citation["ref_id"]
153
+ cited_value = citation["cited_value"]
154
+ cited_normalized = citation["normalized"]
155
+
156
+ detail = {
157
+ "ref_id": ref_id,
158
+ "cited_value": cited_value,
159
+ "cited_normalized": cited_normalized,
160
+ "status": "unknown"
161
+ }
162
+
163
+ # Check if reference exists
164
+ if ref_id not in metric_reference:
165
+ result["missing_refs"].append(ref_id)
166
+ result["valid"] = False
167
+ detail["status"] = "missing_ref"
168
+ detail["error"] = f"Reference {ref_id} not found in metric table"
169
+ result["details"].append(detail)
170
+ continue
171
+
172
+ ref_entry = metric_reference[ref_id]
173
+ expected_value = ref_entry.get("raw_value")
174
+ metric_key = ref_entry.get("key", "unknown")
175
+ expected_formatted = ref_entry.get("formatted", str(expected_value))
176
+
177
+ detail["metric_key"] = metric_key
178
+ detail["expected_value"] = expected_value
179
+ detail["expected_formatted"] = expected_formatted
180
+
181
+ # Check if values match
182
+ if cited_normalized is None:
183
+ result["mismatches"].append(
184
+ f"{metric_key} [{ref_id}]: could not parse cited value '{cited_value}'"
185
+ )
186
+ result["valid"] = False
187
+ detail["status"] = "parse_error"
188
+ elif not values_match(cited_normalized, expected_value):
189
+ # Format expected value for display
190
+ if abs(expected_value) >= 1e9:
191
+ expected_display = f"${expected_value/1e9:.1f}B"
192
+ elif abs(expected_value) >= 1e6:
193
+ expected_display = f"${expected_value/1e6:.0f}M"
194
+ else:
195
+ expected_display = expected_formatted.split(" (as of")[0] if " (as of" in expected_formatted else expected_formatted
196
+
197
+ result["mismatches"].append(
198
+ f"{metric_key} [{ref_id}]: cited {cited_value}, expected {expected_display}"
199
+ )
200
+ result["valid"] = False
201
+ detail["status"] = "mismatch"
202
+ else:
203
+ detail["status"] = "valid"
204
+
205
+ result["details"].append(detail)
206
+
207
+ return result
208
+
209
+
210
+ def validate_numeric_accuracy(swot_text: str, metric_reference: dict) -> list[str]:
211
+ """
212
+ Main validation function for critic integration.
213
+
214
+ Returns list of mismatch descriptions (empty if all valid).
215
+ """
216
+ if not metric_reference:
217
+ return []
218
+
219
+ result = validate_citations(swot_text, metric_reference)
220
+
221
+ # Combine mismatches and missing refs
222
+ errors = result["mismatches"].copy()
223
+ for ref_id in result["missing_refs"]:
224
+ errors.append(f"Invalid reference: {ref_id} not in metric table")
225
+
226
+ return errors