Skip to content

Commit bb526eb

Browse files
committed
Milestone 1: First Verified Analyst Answer (Gross Margin)
1 parent 4edc60b commit bb526eb

18 files changed

Lines changed: 1061 additions & 4 deletions

finverify-terminal/CAPABILITIES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
| Scale ||||| Stable |
66
| Sign ||||| |
77
| Magnitude ||||| |
8+
| Profitability: Gross Margin ||||| Milestone 1 |
89
| Percentage ||||| Planned |
910
| Basis Points ||||| Planned |
1011
| EPS ||||| Planned |
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"concepts": {
3+
"Revenue": {
4+
"aliases": ["Net Sales", "Turnover", "Sales"],
5+
"xbrl_tags": [
6+
"us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax",
7+
"us-gaap:RevenueFromContractWithCustomerIncludingAssessedTax",
8+
"us-gaap:Revenues"
9+
],
10+
"unit": "USD",
11+
"statement": "IncomeStatement",
12+
"period_rules": "FiscalYear",
13+
"currency_rules": "USD",
14+
"validation": "Must be > 0"
15+
},
16+
"CostOfGoodsSold": {
17+
"aliases": ["COGS", "Cost of Sales", "Cost of Revenue"],
18+
"xbrl_tags": [
19+
"us-gaap:CostOfGoodsSold",
20+
"us-gaap:CostOfGoodsAndServicesSold",
21+
"us-gaap:CostOfSales"
22+
],
23+
"unit": "USD",
24+
"statement": "IncomeStatement",
25+
"period_rules": "FiscalYear",
26+
"currency_rules": "USD",
27+
"validation": "Must be > 0"
28+
},
29+
"GrossProfit": {
30+
"aliases": ["Gross Profit"],
31+
"xbrl_tags": ["us-gaap:GrossProfit"],
32+
"unit": "USD",
33+
"statement": "IncomeStatement"
34+
},
35+
"GrossMargin": {
36+
"aliases": ["Gross Profit Margin"],
37+
"formula": "(Revenue - CostOfGoodsSold) / Revenue",
38+
"unit": "percentage",
39+
"statement": "IncomeStatement",
40+
"requires": ["Revenue", "CostOfGoodsSold"]
41+
}
42+
}
43+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Financial-document reasoning primitives for Milestone 1."""
2+
3+
from .concepts import ConceptRegistry
4+
from .contract import EvidenceContract, EvidenceContractBuilder, EvidenceItem
5+
from .document import FinancialDocument, FinancialPeriod, FinancialStatement, FinancialStatementItem
6+
from .formula import FormulaEngine
7+
from .mapper import StatementMapper
8+
from .parser import TaskParser
9+
from .planner import ExecutionPlanner
10+
from .reasoning import ReasoningEngine
11+
from .tasks import FinancialTask, TaskRegistry, TaskType
12+
13+
__all__ = [
14+
"ConceptRegistry",
15+
"EvidenceContract",
16+
"EvidenceContractBuilder",
17+
"EvidenceItem",
18+
"ExecutionPlanner",
19+
"FinancialDocument",
20+
"FinancialPeriod",
21+
"FinancialStatement",
22+
"FinancialStatementItem",
23+
"FinancialTask",
24+
"FormulaEngine",
25+
"ReasoningEngine",
26+
"StatementMapper",
27+
"TaskParser",
28+
"TaskRegistry",
29+
"TaskType",
30+
]
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Concept registry loader for financial reasoning."""
2+
3+
import json
4+
from pathlib import Path
5+
from typing import Any
6+
7+
try:
8+
import yaml
9+
except ImportError:
10+
yaml = None
11+
12+
13+
class ConceptRegistry:
14+
def __init__(self, config_path: str | Path):
15+
with open(config_path, encoding="utf-8") as handle:
16+
if yaml is not None:
17+
data = yaml.safe_load(handle) or {}
18+
else:
19+
data = json.load(handle)
20+
self.concepts: dict[str, dict[str, Any]] = data.get("concepts", {})
21+
self._build_indexes()
22+
23+
def _build_indexes(self) -> None:
24+
self.alias_map: dict[str, str] = {}
25+
self.tag_map: dict[str, str] = {}
26+
for name, spec in self.concepts.items():
27+
self.alias_map[name.lower()] = name
28+
for alias in spec.get("aliases", []):
29+
self.alias_map[alias.lower()] = name
30+
for tag in spec.get("xbrl_tags", []):
31+
normalized_tag = tag.lower()
32+
self.tag_map[normalized_tag] = name
33+
if ":" in normalized_tag:
34+
self.tag_map[normalized_tag.split(":", 1)[1]] = name
35+
36+
def get_concept(self, name: str) -> dict[str, Any]:
37+
return self.concepts.get(name, {})
38+
39+
def resolve_alias(self, alias: str) -> str | None:
40+
return self.alias_map.get(alias.lower())
41+
42+
def resolve_xbrl_tag(self, tag: str) -> str | None:
43+
return self.tag_map.get(tag.lower())
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Evidence contracts for financial reasoning tasks."""
2+
3+
from pydantic import BaseModel, Field
4+
5+
from .document import FinancialDocument
6+
7+
8+
class EvidenceItem(BaseModel):
9+
concept: str
10+
value: float
11+
unit: str
12+
statement: str
13+
source_ref: str
14+
xbrl_tag: str | None = None
15+
16+
17+
class EvidenceContract(BaseModel):
18+
required: list[str]
19+
provided: list[EvidenceItem]
20+
missing: list[str]
21+
optional: list[EvidenceItem] = Field(default_factory=list)
22+
23+
24+
class EvidenceContractBuilder:
25+
@staticmethod
26+
def build(document: FinancialDocument, required_concepts: list[str]) -> EvidenceContract:
27+
items_by_concept: dict[str, EvidenceItem] = {}
28+
optional: list[EvidenceItem] = []
29+
30+
for statement in document.statements.values():
31+
for item in statement.items:
32+
evidence_item = EvidenceItem(
33+
concept=item.concept,
34+
value=item.value,
35+
unit=item.unit,
36+
statement=statement.name,
37+
source_ref=item.source_ref,
38+
xbrl_tag=item.xbrl_tag,
39+
)
40+
if item.concept in required_concepts and item.concept not in items_by_concept:
41+
items_by_concept[item.concept] = evidence_item
42+
elif item.concept not in required_concepts:
43+
optional.append(evidence_item)
44+
45+
provided = [items_by_concept[concept] for concept in required_concepts if concept in items_by_concept]
46+
missing = [concept for concept in required_concepts if concept not in items_by_concept]
47+
48+
return EvidenceContract(
49+
required=required_concepts,
50+
provided=provided,
51+
missing=missing,
52+
optional=optional,
53+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Canonical financial document models."""
2+
3+
from datetime import date
4+
from typing import Optional
5+
6+
from pydantic import BaseModel, Field
7+
8+
9+
class FinancialPeriod(BaseModel):
10+
start_date: date
11+
end_date: date
12+
fiscal_year: int
13+
fiscal_quarter: Optional[int] = None
14+
15+
16+
class FinancialStatementItem(BaseModel):
17+
concept: str
18+
value: float
19+
unit: str
20+
period: FinancialPeriod
21+
source_ref: str
22+
xbrl_tag: Optional[str] = None
23+
confidence: float = 1.0
24+
25+
26+
class FinancialStatement(BaseModel):
27+
name: str
28+
items: list[FinancialStatementItem] = Field(default_factory=list)
29+
period: FinancialPeriod
30+
currency: str = "USD"
31+
32+
33+
class FinancialDocument(BaseModel):
34+
company_name: str
35+
ticker: Optional[str] = None
36+
cik: Optional[str] = None
37+
filing_type: str
38+
filing_date: date
39+
periods: list[FinancialPeriod] = Field(default_factory=list)
40+
statements: dict[str, FinancialStatement] = Field(default_factory=dict)
41+
footnotes: dict[str, str] = Field(default_factory=dict)
42+
metadata: dict[str, str] = Field(default_factory=dict)
43+
source_url: Optional[str] = None
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Deterministic arithmetic formula evaluation without eval()."""
2+
3+
import ast
4+
import operator
5+
from typing import Mapping
6+
7+
8+
class FormulaEngine:
9+
"""Safe arithmetic evaluator backed by Python's AST."""
10+
11+
_binary_operators = {
12+
ast.Add: operator.add,
13+
ast.Sub: operator.sub,
14+
ast.Mult: operator.mul,
15+
ast.Div: operator.truediv,
16+
}
17+
_unary_operators = {
18+
ast.UAdd: operator.pos,
19+
ast.USub: operator.neg,
20+
}
21+
22+
def evaluate(self, formula: str, values: Mapping[str, float]) -> float:
23+
parsed = ast.parse(formula, mode="eval")
24+
return float(self._evaluate_node(parsed.body, values))
25+
26+
def _evaluate_node(self, node: ast.AST, values: Mapping[str, float]) -> float:
27+
if isinstance(node, ast.BinOp):
28+
return self._evaluate_binary(node, values)
29+
if isinstance(node, ast.UnaryOp) and type(node.op) in self._unary_operators:
30+
operand = self._evaluate_node(node.operand, values)
31+
return self._unary_operators[type(node.op)](operand)
32+
if isinstance(node, ast.Name):
33+
if node.id not in values:
34+
raise KeyError(f"Missing value for concept '{node.id}'")
35+
return float(values[node.id])
36+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
37+
return float(node.value)
38+
if isinstance(node, ast.Num):
39+
return float(node.n)
40+
raise ValueError(f"Unsupported formula node: {ast.dump(node)}")
41+
42+
def _evaluate_binary(self, node: ast.BinOp, values: Mapping[str, float]) -> float:
43+
operator_type = type(node.op)
44+
if operator_type not in self._binary_operators:
45+
raise ValueError(f"Unsupported operator: {operator_type.__name__}")
46+
left = self._evaluate_node(node.left, values)
47+
right = self._evaluate_node(node.right, values)
48+
if operator_type is ast.Div and right == 0:
49+
raise ZeroDivisionError("Division by zero in formula evaluation")
50+
return self._binary_operators[operator_type](left, right)

0 commit comments

Comments
 (0)