vn6295337 commited on
Commit
7abf8e9
·
1 Parent(s): 8a1264b

feat: Deterministic data gate on the Researcher->Analyzer edge

Browse files

Applies the AgentAsk edge-contract principle deterministically (no LLM):
- DG: per-category required-metric rules audited on the exact extracted
view the Analyzer consumes; gaps render as an explicit MISSING section
in the reference table instructing 'DATA NOT PROVIDED' - the silent
empty-valuation case can no longer pass unflagged, and the Critic's
existing constraint rule finally has something to enforce
- SC: sanity bounds quarantine impossible magnitudes (decimal/unit slips)
before they can enter the reference table and be cited; bounds reject
the impossible, not the unusual (Boeing's real 9.92 D/E passes)
- Both findings logged to the activity stream; 7 gate tests added

src/nodes/analyzer.py CHANGED
@@ -970,13 +970,17 @@ def _format_metric_for_reference(key: str, value, temporal_info: dict = None) ->
970
  return formatted, as_of_date
971
 
972
 
973
- def _generate_metric_reference_table(extracted: dict, is_financial: bool = False) -> tuple:
 
974
  """
975
  Generate an immutable metric reference table for LLM grounding.
976
 
977
  Args:
978
  extracted: Extracted metrics dictionary from _extract_key_metrics()
979
  is_financial: If True, exclude EV/EBITDA
 
 
 
980
 
981
  Returns:
982
  tuple: (table_string, metric_lookup_dict)
@@ -1099,6 +1103,15 @@ def _generate_metric_reference_table(extracted: dict, is_financial: bool = False
1099
  lines.extend(sentiment_lines)
1100
  lines.append("")
1101
 
 
 
 
 
 
 
 
 
 
1102
  lines.append("=" * 60)
1103
  lines.append("")
1104
 
@@ -1250,7 +1263,8 @@ def _build_revision_prompt(
1250
  company_data: str,
1251
  current_draft: str,
1252
  is_financial: bool,
1253
- extracted: dict = None
 
1254
  ) -> str:
1255
  """Build revision prompt with conditional focus areas based on failed criteria.
1256
 
@@ -1267,7 +1281,7 @@ def _build_revision_prompt(
1267
  # Generate metric reference table for revision (same as initial mode)
1268
  reference_table = ""
1269
  if extracted:
1270
- reference_table, _ = _generate_metric_reference_table(extracted, is_financial)
1271
  scores = critique_details.get("scores", {})
1272
 
1273
  # Determine which focus areas to include based on failed criteria
@@ -1406,7 +1420,8 @@ Simply output the improved SWOT as a clean, final deliverable."""
1406
 
1407
 
1408
  def _build_analyzer_prompt(company: str, ticker: str, formatted_data: str,
1409
- is_financial: bool, extracted: dict = None) -> tuple:
 
1410
  """Build analyzer prompt with metric reference table for hallucination prevention.
1411
 
1412
  Args:
@@ -1425,7 +1440,8 @@ def _build_analyzer_prompt(company: str, ticker: str, formatted_data: str,
1425
  ref_hash = ""
1426
 
1427
  if extracted:
1428
- reference_table, metric_lookup = _generate_metric_reference_table(extracted, is_financial)
 
1429
  ref_hash = _compute_reference_hash(metric_lookup)
1430
 
1431
  if is_financial:
@@ -1509,6 +1525,12 @@ def analyzer_node(state, workflow_id=None, progress_store=None):
1509
 
1510
  # Extract and format metrics for better LLM understanding
1511
  extracted = _extract_key_metrics(raw)
 
 
 
 
 
 
1512
  formatted_data = _format_metrics_for_prompt(extracted, is_financial=is_financial)
1513
 
1514
  # Generate detailed data report (shown before SWOT)
@@ -1533,7 +1555,8 @@ def analyzer_node(state, workflow_id=None, progress_store=None):
1533
  company_data=formatted_data,
1534
  current_draft=state.get("draft_report", ""),
1535
  is_financial=is_financial,
1536
- extracted=extracted
 
1537
  )
1538
 
1539
  # Update progress with revision info
@@ -1547,7 +1570,8 @@ def analyzer_node(state, workflow_id=None, progress_store=None):
1547
  _add_activity_log(workflow_id, progress_store, "analyzer",
1548
  f"Calling LLM to generate SWOT analysis...")
1549
  prompt, metric_lookup, ref_hash = _build_analyzer_prompt(
1550
- company, ticker, formatted_data, is_financial, extracted
 
1551
  )
1552
  # Store metric reference for validation (Layer 1 hallucination prevention)
1553
  state["metric_reference"] = metric_lookup
 
970
  return formatted, as_of_date
971
 
972
 
973
+ def _generate_metric_reference_table(extracted: dict, is_financial: bool = False,
974
+ data_gaps: list = None) -> tuple:
975
  """
976
  Generate an immutable metric reference table for LLM grounding.
977
 
978
  Args:
979
  extracted: Extracted metrics dictionary from _extract_key_metrics()
980
  is_financial: If True, exclude EV/EBITDA
981
+ data_gaps: Required metrics found missing by the data gate; rendered
982
+ as an explicit MISSING section so the LLM must say
983
+ "DATA NOT PROVIDED" rather than silently omit or invent
984
 
985
  Returns:
986
  tuple: (table_string, metric_lookup_dict)
 
1103
  lines.extend(sentiment_lines)
1104
  lines.append("")
1105
 
1106
+ if data_gaps:
1107
+ lines.append("[MISSING - REQUIRED DATA NOT AVAILABLE]")
1108
+ lines.append(" The following required metrics could not be obtained.")
1109
+ lines.append(" If a point would depend on one, write exactly: DATA NOT PROVIDED")
1110
+ lines.append(" Do NOT estimate, infer, or substitute values for these:")
1111
+ for gap in data_gaps:
1112
+ lines.append(f" - {gap}")
1113
+ lines.append("")
1114
+
1115
  lines.append("=" * 60)
1116
  lines.append("")
1117
 
 
1263
  company_data: str,
1264
  current_draft: str,
1265
  is_financial: bool,
1266
+ extracted: dict = None,
1267
+ data_gaps: list = None
1268
  ) -> str:
1269
  """Build revision prompt with conditional focus areas based on failed criteria.
1270
 
 
1281
  # Generate metric reference table for revision (same as initial mode)
1282
  reference_table = ""
1283
  if extracted:
1284
+ reference_table, _ = _generate_metric_reference_table(extracted, is_financial, data_gaps=data_gaps)
1285
  scores = critique_details.get("scores", {})
1286
 
1287
  # Determine which focus areas to include based on failed criteria
 
1420
 
1421
 
1422
  def _build_analyzer_prompt(company: str, ticker: str, formatted_data: str,
1423
+ is_financial: bool, extracted: dict = None,
1424
+ data_gaps: list = None) -> tuple:
1425
  """Build analyzer prompt with metric reference table for hallucination prevention.
1426
 
1427
  Args:
 
1440
  ref_hash = ""
1441
 
1442
  if extracted:
1443
+ reference_table, metric_lookup = _generate_metric_reference_table(
1444
+ extracted, is_financial, data_gaps=data_gaps)
1445
  ref_hash = _compute_reference_hash(metric_lookup)
1446
 
1447
  if is_financial:
 
1525
 
1526
  # Extract and format metrics for better LLM understanding
1527
  extracted = _extract_key_metrics(raw)
1528
+
1529
+ # Data gate (SC): drop values the researcher flagged as implausible so
1530
+ # they can never enter the reference table or be cited
1531
+ from src.utils.data_gate import scrub_suspect_metrics
1532
+ extracted = scrub_suspect_metrics(extracted, state.get("suspect_metrics") or [])
1533
+
1534
  formatted_data = _format_metrics_for_prompt(extracted, is_financial=is_financial)
1535
 
1536
  # Generate detailed data report (shown before SWOT)
 
1555
  company_data=formatted_data,
1556
  current_draft=state.get("draft_report", ""),
1557
  is_financial=is_financial,
1558
+ extracted=extracted,
1559
+ data_gaps=state.get("data_gaps")
1560
  )
1561
 
1562
  # Update progress with revision info
 
1570
  _add_activity_log(workflow_id, progress_store, "analyzer",
1571
  f"Calling LLM to generate SWOT analysis...")
1572
  prompt, metric_lookup, ref_hash = _build_analyzer_prompt(
1573
+ company, ticker, formatted_data, is_financial, extracted,
1574
+ data_gaps=state.get("data_gaps")
1575
  )
1576
  # Store metric reference for validation (Layer 1 hallucination prevention)
1577
  state["metric_reference"] = metric_lookup
src/nodes/researcher.py CHANGED
@@ -153,6 +153,23 @@ def researcher_node(state, workflow_id=None, progress_store=None):
153
  state["raw_data"] = json.dumps(result, indent=2, default=str)
154
  state["sources_failed"] = sources_failed
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  print(f" - Sources available: {result['sources_available']}")
157
  if sources_failed:
158
  print(f" - Sources failed: {sources_failed}")
 
153
  state["raw_data"] = json.dumps(result, indent=2, default=str)
154
  state["sources_failed"] = sources_failed
155
 
156
+ # Deterministic data gate on the exact view the Analyzer consumes:
157
+ # metric-level gaps (DG) and impossible magnitudes (SC)
158
+ try:
159
+ from src.nodes.analyzer import _extract_key_metrics
160
+ from src.utils.data_gate import audit_extracted_metrics
161
+ audit = audit_extracted_metrics(_extract_key_metrics(state["raw_data"]))
162
+ state["data_gaps"] = audit["gaps"]
163
+ state["suspect_metrics"] = audit["suspect"]
164
+ if audit["gaps"]:
165
+ add_log("researcher",
166
+ f"Data gaps (will be marked DATA NOT PROVIDED): {', '.join(audit['gaps'])}")
167
+ for cat, key, value in audit["suspect"]:
168
+ add_log("researcher",
169
+ f"Discarded implausible value: {cat}.{key} = {value:g} (outside sanity bounds)")
170
+ except Exception as gate_err:
171
+ print(f"[data-gate] audit skipped: {gate_err}")
172
+
173
  print(f" - Sources available: {result['sources_available']}")
174
  if sources_failed:
175
  print(f" - Sources failed: {sources_failed}")
src/utils/data_gate.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic data gate for the Researcher -> Analyzer edge.
3
+
4
+ Two checks, run on the same extracted-metrics view the Analyzer consumes:
5
+
6
+ - DG (data gap): required metrics per category. Gaps are surfaced to the
7
+ Analyzer as explicit DATA NOT PROVIDED entries instead of silent omission,
8
+ giving the Critic's constraint-compliance rule something to enforce.
9
+ - SC (signal corruption): impossible magnitudes (unit/decimal slips upstream)
10
+ are quarantined before they can enter the reference table and be cited.
11
+
12
+ Bounds are deliberately loose - they reject the impossible, not the unusual.
13
+ """
14
+
15
+ # Per-category requirement rules: (metric keys, minimum count present)
16
+ REQUIRED_METRICS = {
17
+ "fundamentals": (["revenue", "net_margin", "eps"], 2),
18
+ "valuation": (["pe_trailing", "pe_forward", "pb_ratio", "ps_ratio"], 1),
19
+ "volatility": (["beta", "vix", "historical_volatility"], 1),
20
+ "macro": (["gdp_growth", "interest_rate", "inflation", "unemployment"], 2),
21
+ }
22
+
23
+ # (min, max) inclusive. Values are in the units the extractor produces
24
+ # (margins/rates/vol in percent, ratios as multiples, currency in dollars).
25
+ SANITY_BOUNDS = {
26
+ "revenue": (0, 1e13),
27
+ "net_income": (-1e12, 1e12),
28
+ "free_cash_flow": (-1e12, 1e12),
29
+ "net_margin": (-200, 100),
30
+ "gross_margin": (-200, 100),
31
+ "operating_margin": (-200, 100),
32
+ "eps": (-10000, 10000),
33
+ "debt_to_equity": (-100, 100),
34
+ "revenue_cagr_3yr": (-100, 300),
35
+ "pe_trailing": (-1000, 1000),
36
+ "pe_forward": (-1000, 1000),
37
+ "pb_ratio": (-100, 500),
38
+ "ps_ratio": (0, 500),
39
+ "ev_ebitda": (-1000, 1000),
40
+ "beta": (-5, 10),
41
+ "vix": (5, 150),
42
+ "historical_volatility": (0, 500),
43
+ "gdp_growth": (-30, 30),
44
+ "interest_rate": (-5, 50),
45
+ "inflation": (-20, 100),
46
+ "unemployment": (0, 50),
47
+ }
48
+
49
+
50
+ def _numeric_value(metric_val):
51
+ """Extracted metrics are either numbers or {'value': number, ...} dicts."""
52
+ if isinstance(metric_val, dict):
53
+ metric_val = metric_val.get("value")
54
+ if isinstance(metric_val, (int, float)) and not isinstance(metric_val, bool):
55
+ return float(metric_val)
56
+ return None
57
+
58
+
59
+ def audit_extracted_metrics(extracted: dict) -> dict:
60
+ """
61
+ Audit the extracted metrics view.
62
+
63
+ Args:
64
+ extracted: dict from analyzer._extract_key_metrics -
65
+ {"fundamentals": {...}, "valuation": {...}, ...}
66
+
67
+ Returns:
68
+ {
69
+ "gaps": ["fundamentals: revenue", ...] # DG findings
70
+ "suspect": [("volatility", "vix", 1673.0), ...] # SC findings
71
+ }
72
+ """
73
+ gaps = []
74
+ suspect = []
75
+
76
+ for category, (keys, min_present) in REQUIRED_METRICS.items():
77
+ data = extracted.get(category) or {}
78
+ present = [k for k in keys if _numeric_value(data.get(k)) is not None]
79
+ if len(present) < min_present:
80
+ for k in keys:
81
+ if k not in present:
82
+ gaps.append(f"{category}: {k}")
83
+
84
+ for category in REQUIRED_METRICS:
85
+ data = extracted.get(category) or {}
86
+ if not isinstance(data, dict):
87
+ continue
88
+ for key, raw in data.items():
89
+ value = _numeric_value(raw)
90
+ bounds = SANITY_BOUNDS.get(key)
91
+ if value is None or bounds is None:
92
+ continue
93
+ lo, hi = bounds
94
+ if not (lo <= value <= hi):
95
+ suspect.append((category, key, value))
96
+
97
+ return {"gaps": gaps, "suspect": suspect}
98
+
99
+
100
+ def scrub_suspect_metrics(extracted: dict, suspect: list) -> dict:
101
+ """Remove quarantined values so they cannot enter the reference table."""
102
+ for category, key, _value in suspect:
103
+ if isinstance(extracted.get(category), dict):
104
+ extracted[category].pop(key, None)
105
+ return extracted
tests/test_data_gate.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the deterministic Researcher -> Analyzer data gate."""
2
+
3
+ from src.utils.data_gate import audit_extracted_metrics, scrub_suspect_metrics
4
+
5
+
6
+ def _healthy():
7
+ return {
8
+ "fundamentals": {"revenue": 89.5e9, "net_margin": 2.5, "eps": 2.49},
9
+ "valuation": {"pe_trailing": {"value": 24.1}},
10
+ "volatility": {"beta": 1.15, "vix": 16.73},
11
+ "macro": {"gdp_growth": 2.1, "interest_rate": 3.63},
12
+ }
13
+
14
+
15
+ def test_healthy_data_passes_clean():
16
+ audit = audit_extracted_metrics(_healthy())
17
+ assert audit["gaps"] == []
18
+ assert audit["suspect"] == []
19
+
20
+
21
+ def test_empty_valuation_is_a_gap():
22
+ # The MSFT run shipped an empty valuation basket with no flag anywhere
23
+ data = _healthy()
24
+ data["valuation"] = {}
25
+ audit = audit_extracted_metrics(data)
26
+ assert any(g.startswith("valuation:") for g in audit["gaps"])
27
+
28
+
29
+ def test_missing_fundamentals_below_minimum():
30
+ data = _healthy()
31
+ data["fundamentals"] = {"eps": 2.49} # only 1 of required 2
32
+ audit = audit_extracted_metrics(data)
33
+ assert "fundamentals: revenue" in audit["gaps"]
34
+ assert "fundamentals: net_margin" in audit["gaps"]
35
+
36
+
37
+ def test_impossible_magnitude_is_quarantined():
38
+ data = _healthy()
39
+ data["fundamentals"]["net_margin"] = 1218.0 # decimal-slip: 12.18 -> 1218
40
+ audit = audit_extracted_metrics(data)
41
+ assert ("fundamentals", "net_margin", 1218.0) in audit["suspect"]
42
+
43
+
44
+ def test_unusual_but_possible_values_pass():
45
+ # Boeing's real D/E of 9.92 and hist vol of 34% must NOT be flagged
46
+ data = _healthy()
47
+ data["fundamentals"]["debt_to_equity"] = 9.92
48
+ data["volatility"]["historical_volatility"] = 34.13
49
+ audit = audit_extracted_metrics(data)
50
+ assert audit["suspect"] == []
51
+
52
+
53
+ def test_scrub_removes_only_quarantined_values():
54
+ data = _healthy()
55
+ data["volatility"]["vix"] = 1673.0
56
+ audit = audit_extracted_metrics(data)
57
+ scrubbed = scrub_suspect_metrics(data, audit["suspect"])
58
+ assert "vix" not in scrubbed["volatility"]
59
+ assert scrubbed["volatility"]["beta"] == 1.15
60
+
61
+
62
+ def test_dict_wrapped_values_audited():
63
+ data = _healthy()
64
+ data["fundamentals"]["net_margin"] = {"value": 1218.0, "end_date": "2025-12-31"}
65
+ audit = audit_extracted_metrics(data)
66
+ assert ("fundamentals", "net_margin", 1218.0) in audit["suspect"]