formulagate 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. formulagate/__init__.py +158 -0
  2. formulagate/agents/__init__.py +42 -0
  3. formulagate/agents/arxiv_scanner.py +388 -0
  4. formulagate/agents/cross_domain_linker.py +297 -0
  5. formulagate/agents/formula_classifier.py +404 -0
  6. formulagate/agents/graph.py +375 -0
  7. formulagate/agents/industry_researcher.py +355 -0
  8. formulagate/agents/quantum_specialist.py +406 -0
  9. formulagate/api.py +776 -0
  10. formulagate/atomic.py +437 -0
  11. formulagate/audit.py +62 -0
  12. formulagate/auth.py +206 -0
  13. formulagate/bench.py +68 -0
  14. formulagate/billing.py +379 -0
  15. formulagate/cache.py +364 -0
  16. formulagate/calibration.py +863 -0
  17. formulagate/cli.py +680 -0
  18. formulagate/conformal.py +233 -0
  19. formulagate/corpus.py +23 -0
  20. formulagate/database.py +1302 -0
  21. formulagate/dense.py +148 -0
  22. formulagate/dimensions.py +616 -0
  23. formulagate/domain.py +839 -0
  24. formulagate/domain_ml.py +234 -0
  25. formulagate/domain_tables.py +232 -0
  26. formulagate/equivalence.py +178 -0
  27. formulagate/formula_extract.py +718 -0
  28. formulagate/gate.py +468 -0
  29. formulagate/grounding.py +259 -0
  30. formulagate/hybrid.py +125 -0
  31. formulagate/index_cache.py +132 -0
  32. formulagate/langchain.py +488 -0
  33. formulagate/metrics.py +190 -0
  34. formulagate/middleware.py +591 -0
  35. formulagate/paper_defs.py +83 -0
  36. formulagate/physics_constraints.py +313 -0
  37. formulagate/physics_signals.py +217 -0
  38. formulagate/plans.py +179 -0
  39. formulagate/py.typed +0 -0
  40. formulagate/rag.py +172 -0
  41. formulagate/rerank.py +128 -0
  42. formulagate/retriever.py +244 -0
  43. formulagate/scoring.py +581 -0
  44. formulagate/sdk.py +475 -0
  45. formulagate/semantic_entropy.py +245 -0
  46. formulagate/semantic_gate.py +244 -0
  47. formulagate/symbol_grounding.py +760 -0
  48. formulagate/symbol_ontology.py +143 -0
  49. formulagate/tex_ingest.py +261 -0
  50. formulagate/verify_smt.py +658 -0
  51. formulagate/weak_supervision.py +107 -0
  52. formulagate-1.0.1.dist-info/METADATA +531 -0
  53. formulagate-1.0.1.dist-info/RECORD +56 -0
  54. formulagate-1.0.1.dist-info/WHEEL +5 -0
  55. formulagate-1.0.1.dist-info/entry_points.txt +3 -0
  56. formulagate-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,158 @@
1
+ """Formulagate: a physics-aware gate that abstains instead of hallucinating.
2
+
3
+ Integrators should import :mod:`formulagate.sdk` — a small, stable surface
4
+ (``Formulagate``, ``verify``, ``check``). Everything exported here is the
5
+ internal machinery those two calls are built from, and it may move between
6
+ minor versions.
7
+ """
8
+
9
+ from formulagate.atomic import (
10
+ AtomicClaim,
11
+ AtomicReport,
12
+ AtomicVerdict,
13
+ AtomicVerifier,
14
+ decompose_rule,
15
+ )
16
+ from formulagate.audit import build_rag_audit
17
+ from formulagate.bench import RerankBenchmark, benchmark_rerankers
18
+ from formulagate.calibration import (
19
+ CalibrationError,
20
+ CalibrationParams,
21
+ CalibrationReport,
22
+ MultiCalibration,
23
+ calibrate_confidence,
24
+ evaluate_calibration,
25
+ fit_calibration,
26
+ fit_multi_calibration,
27
+ get_calibration,
28
+ get_multi_calibration,
29
+ load_calibration,
30
+ load_multi_calibration,
31
+ save_calibration,
32
+ save_multi_calibration,
33
+ set_calibration,
34
+ set_multi_calibration,
35
+ )
36
+ from formulagate.conformal import (
37
+ ConformalCalibration,
38
+ ConformalError,
39
+ conformal_threshold,
40
+ fit_conformal,
41
+ )
42
+ from formulagate.dense import DenseHybridRetriever, HashEmbedder, SentenceTransformerEmbedder
43
+ from formulagate.dimensions import Dimension, DimensionVerdict, check_dimensions
44
+ from formulagate.equivalence import EquivalenceVerdict, check_equivalence
45
+ from formulagate.formula_extract import (
46
+ Formula,
47
+ canonicalize,
48
+ extract_formulas,
49
+ extract_latex_spans,
50
+ structural_match,
51
+ )
52
+ from formulagate.gate import (
53
+ DiscoveryResult,
54
+ GateEvaluation,
55
+ GateSignals,
56
+ discover_linkages,
57
+ evaluate_gate,
58
+ evaluate_gate_signals,
59
+ )
60
+ from formulagate.grounding import (
61
+ GroundingScore,
62
+ check_grounding,
63
+ semantic_grounding,
64
+ token_level_grounding,
65
+ )
66
+ from formulagate.hybrid import HybridRrfRetriever, rrf_fuse
67
+ from formulagate.metrics import (
68
+ AbstainReport,
69
+ evaluate_abstain_cases,
70
+ evaluate_golden_cases,
71
+ load_golden_cases,
72
+ )
73
+ from formulagate.physics_constraints import (
74
+ ConstraintVerdict,
75
+ ConstraintsReport,
76
+ check_physics_constraints,
77
+ )
78
+ from formulagate.physics_signals import PhysicsSignals, physics_signals
79
+ from formulagate.rag import LexicalStubRetriever, RagDecision, run_scientific_rag
80
+ from formulagate.rerank import CrossEncoderReranker, FormulagateReranker, RerankingRetriever
81
+ from formulagate.scoring import SCORE_WEIGHT_REL, score_record
82
+ from formulagate.semantic_entropy import (
83
+ SemanticEntropy,
84
+ compute_semantic_entropy,
85
+ jaccard_cluster,
86
+ nli_cluster,
87
+ )
88
+
89
+ __all__ = [
90
+ "SCORE_WEIGHT_REL",
91
+ "AbstainReport",
92
+ "AtomicClaim",
93
+ "AtomicReport",
94
+ "AtomicVerdict",
95
+ "AtomicVerifier",
96
+ "CalibrationError",
97
+ "CalibrationParams",
98
+ "CalibrationReport",
99
+ "ConformalCalibration",
100
+ "ConformalError",
101
+ "CrossEncoderReranker",
102
+ "DenseHybridRetriever",
103
+ "Dimension",
104
+ "DimensionVerdict",
105
+ "DiscoveryResult",
106
+ "EquivalenceVerdict",
107
+ "Formula",
108
+ "FormulagateReranker",
109
+ "GateEvaluation",
110
+ "GateSignals",
111
+ "GroundingScore",
112
+ "HashEmbedder",
113
+ "HybridRrfRetriever",
114
+ "LexicalStubRetriever",
115
+ "MultiCalibration",
116
+ "PhysicsSignals",
117
+ "RagDecision",
118
+ "RerankBenchmark",
119
+ "RerankingRetriever",
120
+ "SemanticEntropy",
121
+ "SentenceTransformerEmbedder",
122
+ "benchmark_rerankers",
123
+ "build_rag_audit",
124
+ "calibrate_confidence",
125
+ "canonicalize",
126
+ "check_dimensions",
127
+ "check_equivalence",
128
+ "check_grounding",
129
+ "conformal_threshold",
130
+ "decompose_rule",
131
+ "discover_linkages",
132
+ "evaluate_abstain_cases",
133
+ "evaluate_calibration",
134
+ "evaluate_gate",
135
+ "evaluate_gate_signals",
136
+ "evaluate_golden_cases",
137
+ "extract_formulas",
138
+ "extract_latex_spans",
139
+ "fit_calibration",
140
+ "fit_conformal",
141
+ "fit_multi_calibration",
142
+ "get_calibration",
143
+ "get_multi_calibration",
144
+ "load_calibration",
145
+ "load_golden_cases",
146
+ "load_multi_calibration",
147
+ "physics_signals",
148
+ "rrf_fuse",
149
+ "run_scientific_rag",
150
+ "save_calibration",
151
+ "save_multi_calibration",
152
+ "score_record",
153
+ "set_calibration",
154
+ "set_multi_calibration",
155
+ "structural_match",
156
+ ]
157
+
158
+ __version__ = "1.0.1"
@@ -0,0 +1,42 @@
1
+ """Formulagate discovery agents.
2
+
3
+ Discovery Layer (citable sources only):
4
+ 1. ArxivScanner — Extract formulas from arXiv papers
5
+ 2. QuantumSpecialist — Quantum-physics-focused arXiv discovery
6
+
7
+ Enrichment Layer:
8
+ 3. FormulaClassifier — Tag formulas with domain, subdomain, industries
9
+ 4. IndustryResearcher — Map formulas to real-world industry problems
10
+
11
+ Application Layer:
12
+ 5. CrossDomainLinker — Find physics solutions for an industry problem
13
+
14
+ All formulas pass through the Formulagate Semantic Gate for validation.
15
+ Agents that produced simulated observations (synthetic trajectories, caption
16
+ placeholders) were removed: a formula corpus is only as trustworthy as the
17
+ provenance of its weakest record.
18
+
19
+ Usage:
20
+ from formulagate.agents import AgentSupervisor
21
+ supervisor = AgentSupervisor()
22
+ results = await supervisor.run()
23
+ """
24
+
25
+ from formulagate.agents.arxiv_scanner import ArxivScanner
26
+ from formulagate.agents.quantum_specialist import QuantumSpecialist
27
+ from formulagate.agents.formula_classifier import FormulaClassifier
28
+ from formulagate.agents.industry_researcher import IndustryResearcher
29
+ from formulagate.agents.cross_domain_linker import CrossDomainLinker
30
+ from formulagate.agents.graph import AgentSupervisor, AgentState, FormulaDiscovery, BaseAgent
31
+
32
+ __all__ = [
33
+ "ArxivScanner",
34
+ "QuantumSpecialist",
35
+ "FormulaClassifier",
36
+ "IndustryResearcher",
37
+ "CrossDomainLinker",
38
+ "AgentSupervisor",
39
+ "AgentState",
40
+ "FormulaDiscovery",
41
+ "BaseAgent",
42
+ ]
@@ -0,0 +1,388 @@
1
+ """Agent 1: arXiv Formula Scanner.
2
+
3
+ Scans arXiv papers for physics formulas using the arXiv API and Ollama/LLM
4
+ for formula extraction. Each formula passes through Formulagate's gate.
5
+
6
+ Workflow (LangGraph-compatible):
7
+ 1. fetch_arxiv — Query arXiv API for physics papers
8
+ 2. extract_text — Download paper abstract/text
9
+ 3. extract_formulas — Use Ollama/LLM to find LaTeX formulas
10
+ 4. validate_gate — Pass through Formulagate Semantic Gate
11
+ 5. store_result — Add to corpus
12
+
13
+ Supports:
14
+ - Ollama (local, free) — qwen2.5, llama3
15
+ - DeepSeek API (cloud, high quality) — when API key available
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import re
23
+ import urllib.request
24
+ import urllib.parse
25
+ import xml.etree.ElementTree as ET
26
+ from pathlib import Path
27
+
28
+ from formulagate.agents.graph import BaseAgent, FormulaDiscovery
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ─── arXiv API ────────────────────────────────────────────────────────────────
33
+
34
+ ARXIV_API = "http://export.arxiv.org/api/query"
35
+
36
+ PHYSICS_CATEGORIES = [
37
+ "hep-th", # High Energy Physics - Theory
38
+ "hep-ph", # High Energy Physics - Phenomenology
39
+ "gr-qc", # General Relativity and Quantum Cosmology
40
+ "quant-ph", # Quantum Physics
41
+ "cond-mat", # Condensed Matter
42
+ "astro-ph", # Astrophysics
43
+ "physics", # General Physics
44
+ "nucl-th", # Nuclear Theory
45
+ "hep-ex", # High Energy Physics - Experiment
46
+ "hep-lat", # High Energy Physics - Lattice
47
+ ]
48
+
49
+ # ─── Ollama / LLM prompts ─────────────────────────────────────────────────────
50
+
51
+ FORMULA_EXTRACTION_PROMPT = """You are a PhD physicist extracting formulas from a research paper.
52
+ Extract ALL LaTeX formulas from this text. For each formula:
53
+ 1. Write the LaTeX inside $$...$$
54
+ 2. Give a ONE-LINE description of what it represents
55
+ 3. State the physics domain (e.g., Quantum Field Theory, General Relativity, etc.)
56
+
57
+ Return as JSON list:
58
+ [{"formula": "E = mc^2", "description": "Mass-energy equivalence", "domain": "Relativity"}]
59
+
60
+ Text to analyze:
61
+ {text}
62
+
63
+ Return ONLY valid JSON. No extra text."""
64
+
65
+
66
+ FORMULA_VALIDATION_PROMPT = """Validate this physics formula:
67
+
68
+ Formula: {formula}
69
+ Description: {description}
70
+ Domain: {domain}
71
+
72
+ Check:
73
+ 1. Dimensional analysis — do units match?
74
+ 2. Physical consistency — does it violate known conservation laws?
75
+ 3. Mathematical correctness — proper LaTeX, well-formed expression?
76
+
77
+ Return JSON:
78
+ {{"valid": true/false, "confidence": 0.0-1.0, "issues": ["..."], "corrected_formula": "..."}}"""
79
+
80
+ # ─── Agent Implementation ─────────────────────────────────────────────────────
81
+
82
+
83
+ class ArxivScanner(BaseAgent):
84
+ """Scans arXiv for physics formulas.
85
+
86
+ Args:
87
+ corpus_path: Where to save discovered formulas
88
+ max_results: Max papers per query
89
+ use_ollama: Use local Ollama (True) or cloud API (False)
90
+ ollama_model: Ollama model name for extraction
91
+ openrouter_key: OpenRouter API key (uses DeepSeek via OpenRouter)
92
+ openrouter_model: Model on OpenRouter (default: deepseek/deepseek-chat)
93
+ deepseek_key: Direct DeepSeek API key (legacy, prefer openrouter_key)
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ corpus_path: Path | None = None,
99
+ max_results: int = 5,
100
+ use_ollama: bool = True,
101
+ ollama_model: str = "qwen2.5:1.5b",
102
+ openrouter_key: str | None = None,
103
+ openrouter_model: str = "deepseek/deepseek-chat",
104
+ deepseek_key: str | None = None,
105
+ ):
106
+ super().__init__("arxiv", corpus_path)
107
+ self.max_results = max_results
108
+ self.use_ollama = use_ollama
109
+ self.ollama_model = ollama_model
110
+ self.openrouter_key = openrouter_key
111
+ self.openrouter_model = openrouter_model
112
+ self.deepseek_key = deepseek_key # Legacy direct API
113
+
114
+ async def run(self, state) -> list[FormulaDiscovery]:
115
+ """Main entry point for LangGraph."""
116
+ self.log(f"Scanning arXiv (max={self.max_results} papers)...")
117
+ discoveries = []
118
+
119
+ for category in PHYSICS_CATEGORIES[:3]: # Top 3 categories for speed
120
+ papers = self._fetch_arxiv_papers(category, max_results=self.max_results)
121
+ self.log(f" {category}: {len(papers)} papers found")
122
+
123
+ for paper in papers:
124
+ try:
125
+ formulas = self._extract_formulas(paper)
126
+ for f_data in formulas:
127
+ formula = f_data.get("formula", "")
128
+ desc = f_data.get("description", "")
129
+ domain = f_data.get("domain", "Physics")
130
+
131
+ if not formula or len(formula) < 5:
132
+ continue
133
+
134
+ # Validate through Formulagate Gate
135
+ brief = f"{domain}: {desc}"
136
+ ok, detail = self.validate_with_gate(brief, formula)
137
+
138
+ discovery = FormulaDiscovery(
139
+ agent="arxiv",
140
+ source=paper.get("title", "Unknown"),
141
+ source_url=paper.get("url", ""),
142
+ brief=brief,
143
+ formula=formula,
144
+ domain=domain,
145
+ confidence=f_data.get("confidence", 0.5),
146
+ gate_passed=ok,
147
+ gate_detail=detail,
148
+ )
149
+ discoveries.append(discovery)
150
+
151
+ status = "✅" if ok else "❌"
152
+ self.log(f" {status} {formula[:60]}…")
153
+
154
+ except Exception as exc:
155
+ logger.warning("arXiv paper extraction failed: %s", exc)
156
+ continue
157
+
158
+ self.log(f"Done: {len(discoveries)} formulas discovered")
159
+ return discoveries
160
+
161
+ def _fetch_arxiv_papers(self, category: str, max_results: int = 5) -> list[dict]:
162
+ """Query arXiv API for recent papers in a category."""
163
+ query = f"cat:{category}"
164
+ params = {
165
+ "search_query": query,
166
+ "start": "0",
167
+ "max_results": str(max_results),
168
+ "sortBy": "submittedDate",
169
+ "sortOrder": "descending",
170
+ }
171
+ url = f"{ARXIV_API}?{urllib.parse.urlencode(params)}"
172
+
173
+ try:
174
+ req = urllib.request.Request(url, headers={"User-Agent": "Formulagate/0.3"})
175
+ with urllib.request.urlopen(req, timeout=30) as resp:
176
+ data = resp.read().decode("utf-8")
177
+
178
+ root = ET.fromstring(data)
179
+ ns = {
180
+ "atom": "http://www.w3.org/2005/Atom",
181
+ "arxiv": "http://arxiv.org/schemas/atom",
182
+ }
183
+
184
+ papers = []
185
+ for entry in root.findall("atom:entry", ns):
186
+ title = entry.find("atom:title", ns)
187
+ summary = entry.find("atom:summary", ns)
188
+ link = entry.find("atom:id", ns)
189
+
190
+ title_text = title.text.strip() if title is not None and title.text else ""
191
+ summary_text = summary.text.strip() if summary is not None and summary.text else ""
192
+ link_text = link.text.strip() if link is not None and link.text else ""
193
+
194
+ # Skip if no useful content
195
+ if len(summary_text) < 50:
196
+ continue
197
+
198
+ papers.append({
199
+ "title": title_text,
200
+ "summary": summary_text,
201
+ "url": link_text,
202
+ "category": category,
203
+ })
204
+
205
+ return papers
206
+
207
+ except Exception as exc:
208
+ # No synthetic fallback: a failed fetch must yield zero papers, never
209
+ # invented ones, or the corpus silently absorbs fabricated physics.
210
+ logger.warning("arXiv API failed for %s: %s", category, exc)
211
+ return []
212
+
213
+ def _extract_formulas(self, paper: dict) -> list[dict]:
214
+ """Extract LaTeX formulas from a paper using available LLM."""
215
+ text = f"Title: {paper.get('title', '')}\n\nAbstract: {paper.get('summary', '')}"
216
+
217
+ # Priority: OpenRouter → Ollama → Direct DeepSeek → Regex
218
+ if self.openrouter_key:
219
+ return self._extract_with_openrouter(text)
220
+ elif self.use_ollama:
221
+ return self._extract_with_ollama(text)
222
+ elif self.deepseek_key:
223
+ return self._extract_with_deepseek(text)
224
+ else:
225
+ return self._extract_regex(text)
226
+
227
+ def _extract_with_ollama(self, text: str) -> list[dict]:
228
+ """Use local Ollama for formula extraction."""
229
+ prompt = FORMULA_EXTRACTION_PROMPT.format(text=text[:3000])
230
+
231
+ try:
232
+ payload = json.dumps({
233
+ "model": self.ollama_model,
234
+ "prompt": prompt,
235
+ "stream": False,
236
+ "options": {"temperature": 0.1, "num_predict": 1000},
237
+ }).encode("utf-8")
238
+
239
+ req = urllib.request.Request(
240
+ "http://127.0.0.1:11434/api/generate",
241
+ data=payload,
242
+ headers={"Content-Type": "application/json"},
243
+ method="POST",
244
+ )
245
+ with urllib.request.urlopen(req, timeout=120) as resp:
246
+ data = json.loads(resp.read().decode("utf-8"))
247
+ response = str(data.get("response", ""))
248
+
249
+ # Try to parse JSON from response
250
+ return self._parse_formula_json(response)
251
+
252
+ except Exception as exc:
253
+ logger.warning("Ollama extraction failed: %s", exc)
254
+ return self._extract_regex(text)
255
+
256
+ def _extract_with_openrouter(self, text: str) -> list[dict]:
257
+ """Use OpenRouter (DeepSeek) for formula extraction."""
258
+ prompt = FORMULA_EXTRACTION_PROMPT.format(text=text[:3000])
259
+
260
+ try:
261
+ payload = json.dumps({
262
+ "model": self.openrouter_model,
263
+ "messages": [
264
+ {"role": "system", "content": "You are a PhD physicist. Return ONLY valid JSON."},
265
+ {"role": "user", "content": prompt},
266
+ ],
267
+ "temperature": 0.1,
268
+ "max_tokens": 1000,
269
+ }).encode("utf-8")
270
+
271
+ req = urllib.request.Request(
272
+ "https://openrouter.ai/api/v1/chat/completions",
273
+ data=payload,
274
+ headers={
275
+ "Content-Type": "application/json",
276
+ "Authorization": f"Bearer {self.openrouter_key}",
277
+ "HTTP-Referer": "https://github.com/formulagate", # Optional: for rankings
278
+ "X-Title": "Formulagate Physics Discovery",
279
+ },
280
+ method="POST",
281
+ )
282
+ with urllib.request.urlopen(req, timeout=120) as resp:
283
+ data = json.loads(resp.read().decode("utf-8"))
284
+
285
+ response = data["choices"][0]["message"]["content"]
286
+ logger.info("OpenRouter response: %s", response[:200])
287
+ return self._parse_formula_json(response)
288
+
289
+ except Exception as exc:
290
+ logger.warning("OpenRouter extraction failed: %s", exc)
291
+ return self._extract_regex(text)
292
+
293
+ def _extract_with_deepseek(self, text: str) -> list[dict]:
294
+ """Use DeepSeek API for high-quality formula extraction."""
295
+ prompt = FORMULA_EXTRACTION_PROMPT.format(text=text[:3000])
296
+
297
+ try:
298
+ payload = json.dumps({
299
+ "model": "deepseek-v4-flash",
300
+ "messages": [{"role": "user", "content": prompt}],
301
+ "temperature": 0.1,
302
+ "max_tokens": 1000,
303
+ }).encode("utf-8")
304
+
305
+ req = urllib.request.Request(
306
+ "https://api.deepseek.com/v1/chat/completions",
307
+ data=payload,
308
+ headers={
309
+ "Content-Type": "application/json",
310
+ "Authorization": f"Bearer {self.deepseek_key}",
311
+ },
312
+ method="POST",
313
+ )
314
+ with urllib.request.urlopen(req, timeout=60) as resp:
315
+ data = json.loads(resp.read().decode("utf-8"))
316
+ response = data["choices"][0]["message"]["content"]
317
+ return self._parse_formula_json(response)
318
+
319
+ except Exception as exc:
320
+ logger.warning("DeepSeek extraction failed: %s", exc)
321
+ return self._extract_regex(text)
322
+
323
+ def _extract_regex(self, text: str) -> list[dict]:
324
+ """Fallback: regex-based LaTeX formula extraction."""
325
+ formulas = []
326
+
327
+ # Match $$...$$ and $...$ patterns
328
+ patterns = [
329
+ r'\$\$(.+?)\$\$',
330
+ r'\$(.+?)\$',
331
+ r'\\\[(.+?)\\\]',
332
+ r'\\\((.+?)\\\)',
333
+ r'\\begin\{equation\}(.+?)\\end\{equation\}',
334
+ r'\\begin\{align\}(.+?)\\end\{align\}',
335
+ ]
336
+
337
+ seen = set()
338
+ for pattern in patterns:
339
+ matches = re.findall(pattern, text, re.DOTALL)
340
+ for match in matches:
341
+ formula = match.strip()
342
+ if len(formula) > 5 and formula not in seen:
343
+ seen.add(formula)
344
+ formulas.append({
345
+ "formula": formula[:500],
346
+ "description": "Extracted from paper abstract",
347
+ "domain": "Physics",
348
+ "confidence": 0.3,
349
+ })
350
+
351
+ return formulas[:5]
352
+
353
+ def _parse_formula_json(self, response: str) -> list[dict]:
354
+ """Parse JSON formula list from LLM response."""
355
+ # Try direct JSON parse
356
+ try:
357
+ data = json.loads(response)
358
+ if isinstance(data, list):
359
+ return data
360
+ if isinstance(data, dict) and "formulas" in data:
361
+ return data["formulas"]
362
+ except json.JSONDecodeError:
363
+ pass
364
+
365
+ # Try to extract JSON array from response
366
+ json_match = re.search(r'\[.*\]', response, re.DOTALL)
367
+ if json_match:
368
+ try:
369
+ return json.loads(json_match.group(0))
370
+ except json.JSONDecodeError:
371
+ pass
372
+
373
+ # Fallback: regex formulas from response text
374
+ return self._extract_regex(response)
375
+
376
+
377
+ # ─── CLI test ─────────────────────────────────────────────────────────────────
378
+
379
+ if __name__ == "__main__":
380
+ import asyncio
381
+
382
+ async def main():
383
+ scanner = ArxivScanner(max_results=2, use_ollama=False)
384
+ discoveries = await scanner.run(None)
385
+ for d in discoveries:
386
+ print(f" [{d.domain}] {d.formula[:80]}")
387
+
388
+ asyncio.run(main())