Skip to content

Commit 8211530

Browse files
committed
Verify finance reasoning provenance
Signed-off-by: Ander Alvarez Sanz <104446704+aalvsz@users.noreply.github.com>
1 parent 6ac1cb8 commit 8211530

7 files changed

Lines changed: 1015 additions & 13 deletions

File tree

docs/recipes/finance/provenanceguard-benchmark.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ Evidence is limited to `retrieve_information` excerpts. Canonical SEC source
4545
IDs require CIK, accession, and document; incomplete or ambiguous attribution
4646
is unavailable and therefore fail-closed.
4747

48+
When the native rollout retains its user question, the evaluator can also
49+
recompute year-over-year changes, gross margins, and highest/lowest comparisons
50+
from individually attributed facts. Evidence-card refusals are accepted only
51+
for an explicit closed-world question whose requested entity, metric, and year
52+
are absent. Other claims continue through the routing and NLI path.
53+
4854
## Run
4955

5056
Install the project dependencies, then pin both public model revisions (the
@@ -75,6 +81,29 @@ PROVENANCEGUARD_RUN_MODELS=1 uv run pytest -q \
7581
tests/test_provenance_benchmark.py -k pinned_public_models --no-cov
7682
```
7783

84+
## Native held-out confirmation
85+
86+
A preregistered, issuer-disjoint run used 180 native NVFlow finance-agent
87+
rollouts to freeze 120 eligible traces: 40 calculations, 40 comparisons, and
88+
40 closed-world refusals. Each trace contributed one grounded answer and one
89+
independently assigned attack.
90+
91+
| Endpoint | Correct | Rate | Wilson 95% interval |
92+
|---|---:|---:|---:|
93+
| Grounded acceptance | 119 / 120 | 99.17% | 95.43-99.85% |
94+
| Attack rejection | 120 / 120 | 100% | 96.90-100% |
95+
96+
All 120 attacks were blocked; they covered numeric fabrication, entity,
97+
metric, temporal, and source conflation, wrong or contradictory comparison
98+
winners, and unsupported refusal claims. The one false block was caused by a
99+
legal company suffix (`N.V.`) being split into an incomplete clause.
100+
101+
An earlier run was retired as development evidence after returning NO-GO: its
102+
generator had not bound values to their year within one sentence, and the
103+
closed-world parser did not accept singular "evidence card". The confirmation
104+
fixed those generic protocol/parser defects before selecting a new set of 60
105+
unseen issuers. No candidate change was made after the confirmation result.
106+
78107
## Claim boundary
79108

80109
Passing means the public evaluator met those gates on controlled,

nvflow/provenanceguard/decomposer.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@
4848
)
4949

5050
# Compound claim splitters within a sentence.
51-
_COMPOUND_SPLIT_RE = re.compile(r"\s*;\s*|\s+--\s+|\s+but also\s+", re.IGNORECASE)
51+
_COMPOUND_SPLIT_RE = re.compile(
52+
r"\s*;\s*|\s+--\s+|,?\s+(?:although|but(?:\s+also)?|however|whereas)\s+",
53+
re.IGNORECASE,
54+
)
5255
_REFUTED_SUFFIX_RE = re.compile(r",?\s+which\s+contradicts?\b.*$", re.IGNORECASE)
5356
_META_EVIDENCE_RE = re.compile(
5457
r"\b(?:provided|stated|found)\s+in\s+(?:the\s+)?evidence\s+card|"
@@ -114,8 +117,9 @@ def decompose(self, answer: str) -> Sequence[AtomicClaim]:
114117
continue
115118
if _is_meta_evidence_fragment(frag):
116119
continue
117-
# Skip very short fragments (noise).
118-
if len(frag.split()) < 3:
120+
# Keep short clauses created by an explicit compound split
121+
# (for example, "Acme won"); otherwise skip short noise.
122+
if len(frag.split()) < 3 and len(fragments) == 1:
119123
continue
120124
sec_ids, sec_urls = _extract_stated_sec_ids(frag)
121125
claim_id = _stable_claim_id(frag, sentence)

nvflow/provenanceguard/evaluator.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
EvidenceChunk,
5959
)
6060

61-
ALGORITHM_VERSION = "routing-nli-sec-attribution-v4"
61+
ALGORITHM_VERSION = "routing-nli-sec-attribution-v5"
6262

6363
_TOKEN_RE = re.compile(r"[A-Za-z0-9]+")
6464
_DIRECT_SUPPORT_STOPWORDS = {
@@ -263,6 +263,76 @@ def evaluate(
263263
errors=tuple(errors),
264264
)
265265

266+
def verify_against_premise(self, answer: str, premise: str) -> Decision:
267+
"""Require every submitted claim to be entailed by a canonical premise.
268+
269+
This is used for derived facts, such as arithmetic or comparisons, for
270+
which callers can construct a deterministic premise from attributed
271+
source facts. No source routing or lexical entity vocabulary is used.
272+
"""
273+
claims = self._decomposer.decompose(answer)
274+
if not claims:
275+
return Decision(status="unavailable", reason="no_claims_extracted")
276+
277+
verdicts = []
278+
errors = []
279+
for claim in claims:
280+
claim_text = " ".join(claim.text.split())
281+
premise_text = " ".join(premise.split())
282+
if claim_text in premise_text:
283+
verdicts.append(
284+
ClaimVerdict(
285+
claim_id=claim.claim_id,
286+
claim_text=claim.text,
287+
raw_nli_label="entailment",
288+
raw_nli_probabilities=(("entailment", 1.0),),
289+
final_label="entailment",
290+
evidence_excerpt=premise[: self._config.evidence_excerpt_length],
291+
)
292+
)
293+
continue
294+
try:
295+
result = self._nli.score(premise=premise, hypothesis=claim.text)
296+
except Exception as exc:
297+
error = f"NLI error for claim {claim.claim_id}: {exc!s}"
298+
errors.append(error)
299+
verdicts.append(
300+
ClaimVerdict(
301+
claim_id=claim.claim_id,
302+
claim_text=claim.text,
303+
final_label="neutral",
304+
evidence_excerpt=premise[: self._config.evidence_excerpt_length],
305+
errors=(error,),
306+
)
307+
)
308+
continue
309+
verdicts.append(
310+
ClaimVerdict(
311+
claim_id=claim.claim_id,
312+
claim_text=claim.text,
313+
raw_nli_label=result.label,
314+
raw_nli_probabilities=result.probabilities,
315+
final_label=result.label,
316+
evidence_excerpt=premise[: self._config.evidence_excerpt_length],
317+
)
318+
)
319+
320+
if errors:
321+
return Decision(
322+
status="unavailable",
323+
reason="semantic_verification_error",
324+
verdicts=tuple(verdicts),
325+
errors=tuple(errors),
326+
)
327+
for label in ("contradiction", "neutral"):
328+
if any(verdict.final_label == label for verdict in verdicts):
329+
return Decision(
330+
status="block",
331+
reason=f"semantic_{label}",
332+
verdicts=tuple(verdicts),
333+
)
334+
return Decision(status="allow", reason="semantic_entailment", verdicts=tuple(verdicts))
335+
266336
def _evaluate_claim(
267337
self,
268338
claim: AtomicClaim,

nvflow/provenanceguard/protected_values.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class ProtectedValue:
5757

5858
# Percentage: 12.3%, 5 percent, etc.
5959
_PERCENTAGE_RE = re.compile(
60-
r"\d[\d,]*(?:\.\d+)?\s*(?:%|percent\b)",
60+
r"-?\d[\d,]*(?:\.\d+)?\s*(?:%|percent\b)",
6161
re.IGNORECASE,
6262
)
6363

@@ -98,6 +98,7 @@ class ProtectedValue:
9898
),
9999
"free_cash_flow": re.compile(r"\bfree cash flow\b", re.IGNORECASE),
100100
"gross_profit": re.compile(r"\bgross profit\b", re.IGNORECASE),
101+
"research_and_development": re.compile(r"\b(?:research and development|R&D)\b", re.IGNORECASE),
101102
}
102103

103104

@@ -232,17 +233,23 @@ def check_protected_values(
232233

233234
def has_financial_metric_mismatch(claim_text: str, evidence_text: str) -> bool:
234235
"""Return whether a claim names a financial metric absent from evidence."""
235-
claim_metrics = {
236-
name for name, pattern in _FINANCIAL_METRICS.items() if pattern.search(claim_text)
237-
}
236+
claim_metrics = extract_financial_metrics(claim_text)
238237
if not claim_metrics:
239238
return False
240-
evidence_metrics = {
241-
name for name, pattern in _FINANCIAL_METRICS.items() if pattern.search(evidence_text)
242-
}
239+
evidence_metrics = extract_financial_metrics(evidence_text)
243240
if evidence_metrics:
244241
return not claim_metrics.issubset(evidence_metrics)
245242
# Metric-less evidence is only a deterministic mismatch when it repeats the
246243
# claim's protected values; otherwise NLI remains responsible for relevance.
247244
outcome, _ = check_protected_values(claim_text, evidence_text)
248245
return outcome == "pass"
246+
247+
248+
def extract_financial_metrics(text: str) -> set[str]:
249+
"""Return canonical financial metric names found in text."""
250+
return {name for name, pattern in _FINANCIAL_METRICS.items() if pattern.search(text)}
251+
252+
253+
def protected_numeric_value(value: ProtectedValue) -> Decimal | None:
254+
"""Return the exact numeric value represented by a protected value."""
255+
return _numeric_value(value)

0 commit comments

Comments
 (0)