SPB Git

spb/firc0001_uqo

Public

N0: audit infrastructure (read-only helpers, hash-verified 2324b58 snapshot, spid import shim, schema probe)

Stage D node batch N0 for FIRC-0001. sha256 of the nine snapshot files equals the node copies
(extract/llm.py, rules.py, base.py, taxonomy.py, resolve.py mtime 2026-06-17); both DuckDB files
open read_only=True; TEMP tables allowed; DuckDB temp_directory redirected to ~/firc0001_review/tmp.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 5, 2026) parent 678d233 Browse files

23 changed files +3,242 −0

added code/audit/a00_probe_schema.py +27 −0
@@ -0,0 +1,27 @@
1+"""N0 probe: open both DuckDB files read-only, describe the tables used by N1-N13."""
2+import sys, json
3+from pathlib import Path
4+sys.path.insert(0, str(Path(__file__).resolve().parent))
5+from common import *
6+
7+r = Run("n0_probe")
8+con = connect(SPID_DB)
9+out = {}
10+for t in ["extracted_sections", "project_mentions", "projects", "project_timeline", "spid_sections", "v_sections", "src_filings", "src_sections", "src_companies"]:
11+ out[t] = [tuple(x[:2]) for x in con.execute(f"DESCRIBE {t}").fetchall()]
12+out["views"] = con.execute("select view_name, sql from duckdb_views() where not internal").fetchall()
13+out["counts"] = {t: con.execute(f"select count(*) from {t}").fetchone()[0] for t in ["extracted_sections", "project_mentions", "projects", "project_timeline", "spid_sections"]}
14+out["ledger_words"] = con.execute("select count(*), sum(v.word_count) from extracted_sections e join (select section_id, word_count from src_sections union all select section_id, word_count from spid_sections) v using(section_id)").fetchone()
15+out["parquet_files"] = con.execute("select count(*), sum(length(text))/1e9 from src_sections").fetchone()
16+e = connect(EDGAR_DB)
17+for t in ["filings", "filing_sections", "companies", "annual_panel"]:
18+ out["edgar_"+t] = [tuple(x[:2]) for x in e.execute(f"DESCRIBE {t}").fetchall()]
19+out["edgar_tables"] = [x[0] for x in e.execute("select table_name from duckdb_tables() where not internal").fetchall()]
20+r.write_json("schema.json", out)
21+# can we create a temp table on a read_only connection?
22+try:
23+ con.execute("CREATE TEMP TABLE _t AS SELECT 1 AS x"); out["temp_ok"] = True
24+except Exception as ex:
25+ out["temp_ok"] = str(ex)
26+r.write_json("schema.json", out)
27+r.finish()
added code/audit/common.py +116 −0
@@ -0,0 +1,116 @@
1+"""Shared helpers for the FIRC-0001 Stage D read-only audits (run on M3U96b).
2+
3+Every script opens the two DuckDB files with read_only=True, SET threads=4,
4+SET memory_limit='24GB', writes CSV/JSON under ~/firc0001_review/results/<name>/
5+and a manifest.json (script sha256, DuckDB mtimes, start/end, row counts).
6+Nothing is ever written under ~/UQO_RESEARCH_DB.
7+"""
8+from __future__ import annotations
9+
10+import csv
11+import datetime as dt
12+import hashlib
13+import json
14+import os
15+import sys
16+import time
17+from pathlib import Path
18+
19+import duckdb
20+
21+HOME = Path(os.environ.get("HOME", "~")).expanduser()
22+RDB = HOME / "UQO_RESEARCH_DB"
23+SPID_DB = RDB / "project-db" / "data" / "db" / "spid.duckdb"
24+EDGAR_DB = RDB / "data" / "duckdb" / "edgar_research.duckdb"
25+WORK = HOME / "firc0001_review"
26+RESULTS = WORK / "results"
27+THREADS = int(os.environ.get("FIRC_THREADS", "4"))
28+MEMLIMIT = os.environ.get("FIRC_MEMLIMIT", "24GB")
29+SEED = 20261105
30+
31+
32+def connect(path: Path = SPID_DB) -> duckdb.DuckDBPyConnection:
33+ con = duckdb.connect(str(path), read_only=True)
34+ con.execute(f"SET threads={THREADS}")
35+ con.execute(f"SET memory_limit='{MEMLIMIT}'")
36+ con.execute("SET preserve_insertion_order=false")
37+ # spill files must never land next to the databases (default = <db>.tmp)
38+ (WORK / "tmp").mkdir(parents=True, exist_ok=True)
39+ con.execute(f"SET temp_directory='{WORK / 'tmp'}'")
40+ return con
41+
42+
43+def attach_edgar(con: duckdb.DuckDBPyConnection, alias: str = "edgar") -> None:
44+ con.execute(f"ATTACH '{EDGAR_DB}' AS {alias} (READ_ONLY)")
45+
46+
47+def sha256_file(p: Path) -> str:
48+ h = hashlib.sha256()
49+ with open(p, "rb") as f:
50+ for chunk in iter(lambda: f.read(1 << 20), b""):
51+ h.update(chunk)
52+ return h.hexdigest()
53+
54+
55+class Run:
56+ """Context for one audit script: output dir, timing, manifest."""
57+
58+ def __init__(self, name: str, script: str | None = None, out: str | None = None):
59+ self.name = name
60+ self.script = Path(script or sys.argv[0]).resolve()
61+ self.out = Path(out) if out else RESULTS / name
62+ self.out.mkdir(parents=True, exist_ok=True)
63+ self.t0 = time.time()
64+ self.started = dt.datetime.now().isoformat(timespec="seconds")
65+ self.rows: dict[str, int] = {}
66+ self.notes: dict = {}
67+
68+ def write_csv(self, fname: str, cols: list[str], rows) -> int:
69+ rows = list(rows)
70+ with open(self.out / fname, "w", newline="") as f:
71+ w = csv.writer(f)
72+ w.writerow(cols)
73+ w.writerows(rows)
74+ self.rows[fname] = len(rows)
75+ print(f"[{self.name}] {fname}: {len(rows)} rows", flush=True)
76+ return len(rows)
77+
78+ def dump(self, fname: str, con, sql: str, params=None):
79+ cur = con.execute(sql, params) if params else con.execute(sql)
80+ cols = [d[0] for d in cur.description]
81+ return self.write_csv(fname, cols, cur.fetchall())
82+
83+ def write_json(self, fname: str, obj) -> None:
84+ with open(self.out / fname, "w") as f:
85+ json.dump(obj, f, indent=2, default=str)
86+ print(f"[{self.name}] {fname} written", flush=True)
87+
88+ def finish(self, extra: dict | None = None) -> None:
89+ man = {
90+ "package": self.name,
91+ "script": str(self.script),
92+ "script_sha256": sha256_file(self.script) if self.script.exists() else None,
93+ "started": self.started,
94+ "ended": dt.datetime.now().isoformat(timespec="seconds"),
95+ "wall_seconds": round(time.time() - self.t0, 1),
96+ "node": os.uname().nodename,
97+ "python": sys.version.split()[0],
98+ "duckdb": duckdb.__version__,
99+ "threads": THREADS,
100+ "memory_limit": MEMLIMIT,
101+ "seed": SEED,
102+ "databases": {
103+ str(SPID_DB): {"bytes": SPID_DB.stat().st_size,
104+ "mtime": dt.datetime.fromtimestamp(SPID_DB.stat().st_mtime).isoformat()},
105+ str(EDGAR_DB): {"bytes": EDGAR_DB.stat().st_size,
106+ "mtime": dt.datetime.fromtimestamp(EDGAR_DB.stat().st_mtime).isoformat()},
107+ },
108+ "read_only": True,
109+ "row_counts": self.rows,
110+ "notes": self.notes,
111+ }
112+ if extra:
113+ man.update(extra)
114+ with open(self.out / "manifest.json", "w") as f:
115+ json.dump(man, f, indent=2, default=str)
116+ print(f"[{self.name}] done in {man['wall_seconds']} s", flush=True)
added code/audit/run_all.sh +12 −0
@@ -0,0 +1,12 @@
1+#!/bin/bash
2+# Stage D node batch for FIRC-0001: runs N1..N13 in the plan's order, sequentially, read-only.
3+# usage: bash run_all.sh (on M3U96b, from ~/firc0001_review; scripts/ holds the aNN_*.py copies)
4+set -u
5+cd "$HOME/firc0001_review"
6+ORDER="a01_ledger_reconciliation.py a03_resolver_speccurve.py a04_runs_segmenters.py a04_git_evidence.sh a02_grounding.py a05_focus_coverage.py a06_false_positive_classes.py a09_timeseries_cohort.py a10_universe_artefacts.py a07_8k_item_benchmark.py a08_rules_baseline.py a11_annotation_frame.py a12_env_lock.sh a13_export_stats_check.sh"
7+for S in $ORDER; do
8+ bash scripts/run_one.sh "$S"
9+ NAME=$(basename "$S" | sed -E 's/\.(py|sh)$//')
10+ while [ ! -f "logs/$NAME.done" ]; do sleep 60; done
11+ cat "logs/$NAME.done"
12+done
added code/audit/run_one.sh +13 −0
@@ -0,0 +1,13 @@
1+#!/bin/bash
2+# Run one audit script on M3U96b in the background under nice, with a log and a done-marker.
3+# usage: run_one.sh <script.py|script.sh> [args...] (from ~/firc0001_review)
4+set -u
5+cd "$HOME/firc0001_review"
6+S="$1"; shift
7+NAME=$(basename "$S" | sed -E 's/\.(py|sh)$//')
8+LOG="logs/$NAME.log"; DONE="logs/$NAME.done"
9+rm -f "$DONE"
10+PY="$HOME/UQO_RESEARCH_DB/.venv/bin/python"
11+if [[ "$S" == *.sh ]]; then CMD="bash scripts/$S $*"; else CMD="$PY -I scripts/$S $*"; fi
12+nohup bash -c "echo START \$(date -Iseconds); nice -n 10 env OMP_NUM_THREADS=4 FIRC_THREADS=4 $CMD; echo RC=\$? END \$(date -Iseconds) > $DONE" > "$LOG" 2>&1 &
13+echo "started $NAME pid=$! log=$LOG"
added code/audit/spid/__init__.py +1 −0
@@ -0,0 +1 @@
1+"""spid package shim: hash-verified snapshot copies of the 2324b58 extractor modules (see spid_snapshot/SHA256SUMS)."""
added code/audit/spid/config.py +126 −0
@@ -0,0 +1,126 @@
1+# ============================================================
2+# SPID — configuration & paths
3+# ============================================================
4+from __future__ import annotations
5+
6+import logging
7+import os
8+from dataclasses import dataclass, field
9+from functools import lru_cache
10+from pathlib import Path
11+
12+# project-db/ (this file lives in project-db/spid/config.py)
13+PROJECT_ROOT = Path(__file__).resolve().parent.parent
14+
15+
16+def _load_dotenv() -> None:
17+ """Minimal .env loader (no python-dotenv dependency required)."""
18+ env_path = PROJECT_ROOT / ".env"
19+ if not env_path.exists():
20+ return
21+ for line in env_path.read_text().splitlines():
22+ line = line.strip()
23+ if not line or line.startswith("#") or "=" not in line:
24+ continue
25+ key, _, val = line.partition("=")
26+ key, val = key.strip(), val.strip().strip('"').strip("'")
27+ os.environ.setdefault(key, val)
28+
29+
30+_load_dotenv()
31+# The parent UQO_RESEARCH_DB that holds the parsed corpus.
32+RESEARCH_ROOT = PROJECT_ROOT.parent
33+SOURCE_DATA = RESEARCH_ROOT / "data"
34+
35+
36+def _parse_scope(spec: str) -> dict:
37+ out: dict = {}
38+ for part in (spec or "").split(";"):
39+ part = part.strip()
40+ if not part or ":" not in part:
41+ continue
42+ form, items = part.split(":", 1)
43+ out[form.strip()] = [i.strip() for i in items.split(",") if i.strip()]
44+ return out
45+
46+
47+@dataclass(frozen=True)
48+class Settings:
49+ """Resolved runtime configuration for the SPID platform."""
50+
51+ # --- source corpus (read-only) -------------------------------------
52+ source_data: Path = SOURCE_DATA
53+ parquet_dir: Path = SOURCE_DATA / "parquet"
54+ processed_dir: Path = SOURCE_DATA / "processed"
55+
56+ @property
57+ def filings_parquet(self) -> Path:
58+ return self.parquet_dir / "filings" / "filings.parquet"
59+
60+ @property
61+ def sections_parquet(self) -> Path:
62+ return self.parquet_dir / "filing_sections" / "filing_sections.parquet"
63+
64+ @property
65+ def companies_parquet(self) -> Path:
66+ return self.parquet_dir / "companies" / "companies.parquet"
67+
68+ # --- SPID outputs (writable) ---------------------------------------
69+ db_path: Path = PROJECT_ROOT / "data" / "db" / "spid.duckdb"
70+ out_dir: Path = PROJECT_ROOT / "data"
71+
72+ # --- extraction ----------------------------------------------------
73+ backend: str = field(
74+ default_factory=lambda: os.environ.get("SPID_BACKEND", "llm")
75+ ) # "llm" (gpt-4o-mini) | "rules"
76+ min_confidence: float = 0.45
77+ # which parsed sections feed the extractor, per form (None/empty = every section)
78+ section_scope: dict = field(default_factory=lambda: _parse_scope(os.environ.get(
79+ "SPID_SECTION_SCOPE",
80+ "10-K:business,risk_factors,properties,md_and_a,quantitative_disclosures")))
81+ max_section_chars: int = 600_000
82+
83+ # --- embeddings ----------------------------------------------------
84+ embedding_model: str = field(
85+ default_factory=lambda: os.environ.get("SPID_EMBED_MODEL", "all-MiniLM-L6-v2")
86+ )
87+
88+ # --- LLM backend (OpenAI-compatible) -------------------------------
89+ llm_base_url: str = field(
90+ default_factory=lambda: os.environ.get(
91+ "SPID_LLM_BASE_URL", "https://api.openai.com/v1"
92+ )
93+ )
94+ llm_model: str = field(
95+ default_factory=lambda: os.environ.get("SPID_LLM_MODEL", "gpt-4o-mini")
96+ )
97+ llm_api_key: str = field(
98+ default_factory=lambda: os.environ.get("OPENAI_API_KEY", "")
99+ or os.environ.get("MACLUSTR_API_KEY", "")
100+ )
101+ llm_timeout: float = 120.0
102+
103+ def ensure_dirs(self) -> None:
104+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
105+ (self.out_dir / "exports").mkdir(parents=True, exist_ok=True)
106+
107+
108+@lru_cache(maxsize=1)
109+def get_settings() -> Settings:
110+ s = Settings()
111+ s.ensure_dirs()
112+ return s
113+
114+
115+def get_logger(name: str) -> logging.Logger:
116+ logger = logging.getLogger(f"spid.{name}")
117+ if not logger.handlers:
118+ h = logging.StreamHandler()
119+ h.setFormatter(
120+ logging.Formatter("%(asctime)s %(levelname)-7s %(name)s | %(message)s",
121+ datefmt="%H:%M:%S")
122+ )
123+ logger.addHandler(h)
124+ logger.setLevel(os.environ.get("SPID_LOG_LEVEL", "INFO"))
125+ logger.propagate = False
126+ return logger
added code/audit/spid/extract/__init__.py +0 −0
added code/audit/spid/extract/base.py +69 −0
@@ -0,0 +1,69 @@
1+# ============================================================
2+# SPID — extraction interface & record schema
3+# ============================================================
4+from __future__ import annotations
5+
6+import hashlib
7+from dataclasses import dataclass, field, asdict
8+from typing import Protocol
9+
10+
11+@dataclass
12+class ProjectMention:
13+ """A single project signal extracted from one filing section.
14+
15+ Mentions are the atomic unit; `resolve.py` merges many mentions
16+ (across filings & years) into a single Project with a timeline.
17+ """
18+ mention_id: str
19+ accession_number: str
20+ cik: str
21+ section_id: str
22+ section_name: str
23+
24+ project_name: str
25+ project_type: str
26+ description: str
27+ objective: str = ""
28+
29+ amount_usd: float | None = None
30+ amount_raw: str | None = None
31+ locations: list[str] = field(default_factory=list)
32+ technologies: list[str] = field(default_factory=list)
33+ partners: list[str] = field(default_factory=list)
34+ suppliers: list[str] = field(default_factory=list)
35+ benefits: list[str] = field(default_factory=list)
36+ risks: list[str] = field(default_factory=list)
37+ years: list[int] = field(default_factory=list)
38+ status: str = "mentioned"
39+
40+ confidence: float = 0.0
41+ backend: str = "rules"
42+
43+ def to_row(self) -> dict:
44+ d = asdict(self)
45+ # store list fields as JSON-friendly via duckdb list[str]
46+ return d
47+
48+ @staticmethod
49+ def make_id(accession: str, section_id: str, ptype: str, anchor: str) -> str:
50+ h = hashlib.sha1(f"{accession}|{section_id}|{ptype}|{anchor}".encode()).hexdigest()
51+ return h[:20]
52+
53+
54+class Extractor(Protocol):
55+ name: str
56+
57+ def extract(self, *, text: str, accession_number: str, cik: str,
58+ section_id: str, section_name: str) -> list[ProjectMention]:
59+ ...
60+
61+
62+def get_extractor(backend: str):
63+ """Factory. `backend` in {'rules','llm'}."""
64+ backend = (backend or "rules").lower()
65+ if backend == "llm":
66+ from spid.extract.llm import LLMExtractor
67+ return LLMExtractor()
68+ from spid.extract.rules import RuleExtractor
69+ return RuleExtractor()
added code/audit/spid/extract/llm.py +202 −0
@@ -0,0 +1,202 @@
1+# ============================================================
2+# SPID — LLM extractor (OpenAI-compatible; default gpt-4o-mini)
3+#
4+# Uses the chat-completions JSON mode to extract structured
5+# project records. Cost-controlled: only project-relevant
6+# windows of each section are sent to the model.
7+# ============================================================
8+from __future__ import annotations
9+
10+import json
11+import re
12+import time
13+
14+import httpx
15+
16+from spid import taxonomy as tx
17+from spid.config import get_logger, get_settings
18+from spid.extract.base import ProjectMention
19+from spid.extract.rules import _PROJECT_GATE
20+
21+log = get_logger("extract.llm")
22+
23+_SYSTEM = """You are a financial analyst extracting CORPORATE PROJECTS from SEC filing text.
24+A "project" is any concrete initiative, investment, program, expansion, construction,
25+product/R&D effort, acquisition/integration, technology deployment, partnership, or
26+strategic program the company is pursuing.
27+
28+Return STRICT JSON: {"projects": [ ... ]}. Each project object has:
29+ project_name : short descriptive name (use the official name if one is stated)
30+ project_type : EXACTLY ONE of the allowed types below
31+ description : 1 sentence, grounded in the text (no speculation)
32+ objective : the goal/why (or "")
33+ status : one of planned | in_progress | completed | mentioned
34+ amount_usd : numeric USD figure if stated (e.g. 1500000000), else null
35+ locations : array of places (city/state/country), else []
36+ technologies : array of technologies involved, else []
37+ partners : array of named partners/JV counterparties, else []
38+ benefits : array of expected benefits, else []
39+ risks : array of stated risks, else []
40+ years : array of 4-digit years mentioned, else []
41+ confidence : 0.0-1.0 (how clearly this is a real, concrete project)
42+
43+Allowed project_type values:
44+%s
45+
46+Rules:
47+- Extract ONLY real initiatives, not generic risk-factor boilerplate or hypotheticals.
48+- If the text describes no concrete project, return {"projects": []}.
49+- Do not invent facts. Numbers/locations must appear in the text.
50+- Prefer fewer high-quality projects over many vague ones.""" % "\n".join(
51+ f" - {k}: {v}" for k, v in tx.TYPE_LABELS.items()
52+)
53+
54+
55+def _focus(text: str, budget: int = 9000) -> str:
56+ """Keep only windows around project-signal hits to bound token cost."""
57+ if len(text) <= budget:
58+ return text
59+ spans: list[tuple[int, int]] = []
60+ for m in _PROJECT_GATE.finditer(text):
61+ spans.append((max(0, m.start() - 220), min(len(text), m.end() + 380)))
62+ if sum(b - a for a, b in spans) > budget * 1.4:
63+ break
64+ if not spans:
65+ return text[:budget]
66+ # merge overlapping windows
67+ spans.sort()
68+ merged = [spans[0]]
69+ for a, b in spans[1:]:
70+ if a <= merged[-1][1]:
71+ merged[-1] = (merged[-1][0], max(merged[-1][1], b))
72+ else:
73+ merged.append((a, b))
74+ chunks, total = [], 0
75+ for a, b in merged:
76+ chunks.append(text[a:b])
77+ total += b - a
78+ if total >= budget:
79+ break
80+ return "\n...\n".join(chunks)
81+
82+
83+class LLMExtractor:
84+ name = "llm"
85+
86+ def __init__(self) -> None:
87+ self.s = get_settings()
88+ if not self.s.llm_api_key:
89+ raise RuntimeError("No LLM API key configured (OPENAI_API_KEY / MACLUSTR_API_KEY).")
90+ self._client = httpx.Client(
91+ base_url=self.s.llm_base_url,
92+ headers={"Authorization": f"Bearer {self.s.llm_api_key}"},
93+ timeout=self.s.llm_timeout,
94+ )
95+
96+ def _call(self, content: str, retries: int = 3) -> dict:
97+ payload = {
98+ "model": self.s.llm_model,
99+ "messages": [
100+ {"role": "system", "content": _SYSTEM},
101+ {"role": "user", "content": content},
102+ ],
103+ "temperature": 0,
104+ "response_format": {"type": "json_object"},
105+ "max_tokens": 2000,
106+ }
107+ last = None
108+ for attempt in range(retries):
109+ try:
110+ r = self._client.post("/chat/completions", json=payload)
111+ if r.status_code == 429 or r.status_code >= 500:
112+ raise httpx.HTTPStatusError("retryable", request=r.request, response=r)
113+ r.raise_for_status()
114+ txt = r.json()["choices"][0]["message"]["content"]
115+ return json.loads(txt)
116+ except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e:
117+ last = e
118+ time.sleep(1.5 * (attempt + 1))
119+ log.warning("LLM call failed after %d retries: %s", retries, last)
120+ return {"projects": []}
121+
122+ def extract(self, *, text: str, accession_number: str, cik: str,
123+ section_id: str, section_name: str) -> list[ProjectMention]:
124+ if not text or not _PROJECT_GATE.search(text):
125+ return []
126+ content = (f"SECTION: {section_name}\n\n" + _focus(text))[:14000]
127+ data = self._call(content)
128+ out: list[ProjectMention] = []
129+ for p in data.get("projects", []) or []:
130+ if not isinstance(p, dict):
131+ continue
132+ ptype = self._norm_type(p.get("project_type", ""))
133+ desc = str(p.get("description", "")).strip()[:600]
134+ if not desc:
135+ continue
136+ amount = p.get("amount_usd")
137+ try:
138+ amount = float(amount) if amount not in (None, "", "null") else None
139+ except (TypeError, ValueError):
140+ amount = None
141+ anchor = f"{ptype}:{str(p.get('project_name',''))[:40]}"
142+ conf = p.get("confidence", 0.6)
143+ try:
144+ conf = float(conf)
145+ except (TypeError, ValueError):
146+ conf = 0.6
147+ out.append(ProjectMention(
148+ mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor),
149+ accession_number=accession_number, cik=cik,
150+ section_id=section_id, section_name=section_name,
151+ project_name=str(p.get("project_name") or tx.TYPE_LABELS.get(ptype, ptype))[:200],
152+ project_type=ptype, description=desc,
153+ objective=str(p.get("objective", ""))[:400],
154+ amount_usd=amount,
155+ amount_raw=(f"${amount:,.0f}" if amount else None),
156+ locations=_strlist(p.get("locations")),
157+ technologies=_strlist(p.get("technologies")) or tx.extract_technologies(desc),
158+ partners=_strlist(p.get("partners")),
159+ benefits=_strlist(p.get("benefits")),
160+ risks=_strlist(p.get("risks")),
161+ years=_intlist(p.get("years")) or tx.extract_years(desc),
162+ status=self._norm_status(p.get("status")),
163+ confidence=round(max(0.0, min(1.0, conf)), 3),
164+ backend=self.name,
165+ ))
166+ return out
167+
168+ @staticmethod
169+ def _norm_type(raw: str) -> str:
170+ raw = (raw or "").strip().lower().replace(" ", "_").replace("-", "_")
171+ if raw in tx.PROJECT_TYPES:
172+ return raw
173+ # fuzzy contains match against labels/keys
174+ for k, label in tx.TYPE_LABELS.items():
175+ if raw and (raw in k or raw in label.lower().replace(" ", "_")):
176+ return k
177+ return "technology_deployment"
178+
179+ @staticmethod
180+ def _norm_status(raw) -> str:
181+ raw = (str(raw or "")).strip().lower()
182+ return raw if raw in ("planned", "in_progress", "completed", "mentioned") else "mentioned"
183+
184+
185+def _strlist(v) -> list[str]:
186+ if not v:
187+ return []
188+ if isinstance(v, str):
189+ v = [v]
190+ return [str(x).strip()[:120] for x in v if str(x).strip()][:8]
191+
192+
193+def _intlist(v) -> list[int]:
194+ out = []
195+ for x in (v or []):
196+ try:
197+ y = int(str(x)[:4])
198+ if 1980 <= y <= 2040:
199+ out.append(y)
200+ except (TypeError, ValueError):
201+ continue
202+ return sorted(set(out))
added code/audit/spid/extract/rules.py +120 −0
@@ -0,0 +1,120 @@
1+# ============================================================
2+# SPID — deterministic rule-based extractor
3+#
4+# High-precision baseline that needs NO LLM. Scans section text
5+# sentence-by-sentence for project signals, classifies the type,
6+# and pulls money / location / tech / year / status.
7+# ============================================================
8+from __future__ import annotations
9+
10+import re
11+
12+from spid import taxonomy as tx
13+from spid.extract.base import ProjectMention
14+
15+# split into sentence-ish units; keep it cheap for 100k+ sections
16+_SENT_SPLIT = re.compile(r"(?<=[.;:!?])\s+(?=[A-Z(\"$])")
17+_WS = re.compile(r"\s+")
18+# generic "project-ish" gate so we skip boilerplate sentences fast
19+_PROJECT_GATE = re.compile(
20+ r"\b(project|initiative|program|invest|expan|construct|build|launch|develop|"
21+ r"deploy|implement|acqui|partner|facilit|capacity|capital|migrat|transform|"
22+ r"pipeline|plant|data ?cent|automat|sustainab|renewable|restructur)\w*",
23+ re.IGNORECASE,
24+)
25+_NAMED_PROJECT = re.compile(
26+ r"\b((?:Project|Program|Initiative)\s+[A-Z][A-Za-z0-9\-]+"
27+ r"|[A-Z][A-Za-z0-9\-]+\s+(?:Project|Program|Initiative))\b"
28+)
29+
30+
31+def _clean(s: str) -> str:
32+ return _WS.sub(" ", s).strip()
33+
34+
35+class RuleExtractor:
36+ name = "rules"
37+
38+ def extract(self, *, text: str, accession_number: str, cik: str,
39+ section_id: str, section_name: str) -> list[ProjectMention]:
40+ if not text:
41+ return []
42+ text = text[:600_000]
43+ out: list[ProjectMention] = []
44+ seen: set[str] = set()
45+
46+ for raw_sent in _SENT_SPLIT.split(text):
47+ sent = _clean(raw_sent)
48+ if not (20 <= len(sent) <= 700):
49+ continue
50+ if not _PROJECT_GATE.search(sent):
51+ continue
52+
53+ low = sent.lower()
54+ matched_types: list[str] = []
55+ for ptype, regexes in tx.TYPE_SIGNALS.items():
56+ if any(rx.search(low) for rx in regexes):
57+ matched_types.append(ptype)
58+ if not matched_types:
59+ continue
60+
61+ amount, amount_raw = tx.extract_money(sent)
62+ locations = tx.extract_locations(sent)
63+ techs = tx.extract_technologies(sent)
64+ years = tx.extract_years(sent)
65+ status = tx.detect_status(sent)
66+ nm = _NAMED_PROJECT.search(sent)
67+
68+ # keep at most the 2 strongest types for one sentence
69+ for ptype in matched_types[:2]:
70+ anchor = f"{ptype}:{(locations[0] if locations else '')}:{int(amount or 0)}"
71+ if anchor in seen:
72+ continue
73+ seen.add(anchor)
74+
75+ conf = self._confidence(ptype, amount, locations, years, status, nm, low)
76+ if conf < 0.30:
77+ continue
78+
79+ pname = nm.group(1) if nm else self._synth_name(ptype, locations, techs)
80+ out.append(ProjectMention(
81+ mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor),
82+ accession_number=accession_number, cik=cik,
83+ section_id=section_id, section_name=section_name,
84+ project_name=pname, project_type=ptype,
85+ description=sent[:500],
86+ amount_usd=amount, amount_raw=amount_raw,
87+ locations=locations, technologies=techs, years=years,
88+ status=status, confidence=round(conf, 3), backend=self.name,
89+ ))
90+ return out
91+
92+ @staticmethod
93+ def _synth_name(ptype: str, locations: list[str], techs: list[str]) -> str:
94+ label = tx.TYPE_LABELS.get(ptype, ptype)
95+ if locations:
96+ return f"{label} — {locations[0]}"
97+ if techs:
98+ return f"{label} ({techs[0]})"
99+ return label
100+
101+ @staticmethod
102+ def _confidence(ptype, amount, locations, years, status, nm, low) -> float:
103+ c = 0.40
104+ if nm:
105+ c += 0.20
106+ if amount:
107+ c += 0.18
108+ if locations:
109+ c += 0.10
110+ if years:
111+ c += 0.06
112+ if status in ("planned", "in_progress", "completed"):
113+ c += 0.08
114+ # strong, specific phrasings
115+ if re.search(r"\b(announced|committed|invest\w+|will (build|open|invest))\b", low):
116+ c += 0.06
117+ # de-weight vague risk-factor / forward-looking hedges
118+ if re.search(r"\b(may|might|could|potential\w*|if we|uncertain)\b", low):
119+ c -= 0.08
120+ return max(0.0, min(1.0, c))
added code/audit/spid/taxonomy.py +261 −0
@@ -0,0 +1,261 @@
1+# ============================================================
2+# SPID — project taxonomy & lightweight value extractors
3+#
4+# Shared by both the rule-based and the LLM extractor so that
5+# `project_type`, `technology`, money, location and dates are
6+# normalised the same way regardless of backend.
7+# ============================================================
8+from __future__ import annotations
9+
10+import re
11+
12+# ----------------------------------------------------------------------
13+# Canonical project types. `signals` are high-precision regex fragments
14+# (already lower-cased) used by the rule extractor and as the controlled
15+# vocabulary the LLM is asked to classify into.
16+# ----------------------------------------------------------------------
17+PROJECT_TYPES: dict[str, dict] = {
18+ "manufacturing_expansion": {
19+ "label": "Manufacturing / Capacity Expansion",
20+ "signals": [
21+ r"(expand|increas\w+|add\w*|ramp\w*|scal\w+|new|build\w*|construct\w*|open\w*)"
22+ r"[^.]{0,60}(manufactur\w+|production|plant|factory|facilit\w+|capacity|assembly|fab\b)",
23+ ],
24+ },
25+ "plant_construction": {
26+ "label": "Plant / Site Construction",
27+ "signals": [r"(construct\w*|build\w*|broke ground|commission\w*|new (plant|site|facility))"
28+ r"[^.]{0,60}(plant|facility|site|campus|complex|refinery|mill)"],
29+ },
30+ "data_center": {
31+ "label": "Data Center",
32+ "signals": [r"data\s?cent(er|re)s?", r"hyperscal\w+", r"server farm"],
33+ },
34+ "cloud_migration": {
35+ "label": "Cloud Migration",
36+ "signals": [r"(migrat\w+|mov\w+|transition\w*)[^.]{0,40}(cloud)",
37+ r"cloud (migration|platform|adoption|infrastructure|first)"],
38+ },
39+ "ai_initiative": {
40+ "label": "AI / ML Initiative",
41+ "signals": [r"\b(artificial intelligence|machine learning|generative ai|gen ?ai|deep learning|"
42+ r"large language model|\bllm\b|neural network)\b",
43+ r"\bai[- ](powered|driven|enabled|capabilit\w+|platform|model|tools?)\b"],
44+ },
45+ "digital_transformation": {
46+ "label": "Digital Transformation",
47+ "signals": [r"digital (transformation|transition|initiative|strateg\w+|platform)",
48+ r"digitali[sz]ation"],
49+ },
50+ "erp_implementation": {
51+ "label": "ERP / Systems Implementation",
52+ "signals": [r"\b(erp|sap|oracle|workday|s/?4 ?hana)\b[^.]{0,40}(implement\w+|deploy\w+|migrat\w+|system|platform)",
53+ r"(implement\w+|deploy\w+|upgrad\w+)[^.]{0,40}\berp\b",
54+ r"enterprise resource planning"],
55+ },
56+ "rd_program": {
57+ "label": "R&D Program",
58+ "signals": [r"research and development", r"\br&d\b",
59+ r"(develop\w+|invest\w+)[^.]{0,40}(next[- ]generation|new technolog\w+|new product)"],
60+ },
61+ "product_launch": {
62+ "label": "New Product / Launch",
63+ "signals": [r"(launch\w*|introduc\w+|releas\w+|roll\w*[- ]?out|unveil\w+)"
64+ r"[^.]{0,50}(product|platform|service|offering|solution|model|line)",
65+ r"new product (line|launch|introduction)"],
66+ },
67+ "drug_pipeline": {
68+ "label": "Drug / Clinical Pipeline",
69+ "signals": [r"(phase\s?(i{1,3}|1|2|3)\b)", r"clinical trial", r"\bfda (approval|clearance|submission)\b",
70+ r"(drug|therap\w+|candidate|molecule|pipeline)[^.]{0,40}(develop\w+|trial|approval)"],
71+ },
72+ "ma_integration": {
73+ "label": "M&A / Integration",
74+ "signals": [r"(acqui\w+|merg\w+|takeover|business combination)",
75+ r"integration of [a-z]", r"purchase of [a-z]"],
76+ },
77+ "geographic_expansion": {
78+ "label": "Geographic Expansion",
79+ "signals": [r"(expand\w*|enter\w*|launch\w*|establish\w*)[^.]{0,50}"
80+ r"(market|region|countr\w+|geograph\w+|international|overseas|abroad)",
81+ r"market entry"],
82+ },
83+ "capex_program": {
84+ "label": "Capital Expenditure Program",
85+ "signals": [r"capital (expenditure|expenditures|investment|spending|program|project|budget)",
86+ r"\bcapex\b", r"invest\w+[^.]{0,30}(\$|usd)"],
87+ },
88+ "supply_chain": {
89+ "label": "Supply Chain Initiative",
90+ "signals": [r"supply chain", r"(reshor\w+|nearshor\w+|onshor\w+|offshor\w+)",
91+ r"(distribution|logistics|fulfillment|warehouse|inventory)[^.]{0,40}"
92+ r"(network|center|optimi[sz]\w+|expansion|investment)"],
93+ },
94+ "automation": {
95+ "label": "Automation / Robotics",
96+ "signals": [r"automat\w+", r"robot\w+", r"(industrial|warehouse) automation"],
97+ },
98+ "sustainability": {
99+ "label": "Sustainability / ESG",
100+ "signals": [r"sustainabilit\w+", r"\besg\b", r"net[- ]zero", r"carbon (neutral|reduction|footprint|emissions?)",
101+ r"decarboni[sz]\w+", r"emission reduction"],
102+ },
103+ "energy_transition": {
104+ "label": "Energy Transition / Renewables",
105+ "signals": [r"renewable (energy|power)", r"(solar|wind|hydrogen|geothermal|battery storage)[^.]{0,40}"
106+ r"(project|facility|farm|plant|capacity|investment)",
107+ r"clean energy", r"energy transition"],
108+ },
109+ "infrastructure": {
110+ "label": "Infrastructure Investment",
111+ "signals": [r"infrastructure (investment|project|upgrade|expansion|spending|build\w*)",
112+ r"(network|grid|pipeline|broadband|fiber) (expansion|build\w*|upgrade|deployment)"],
113+ },
114+ "cost_reduction": {
115+ "label": "Cost Reduction / Restructuring",
116+ "signals": [r"(cost (reduction|savings|cutting)|restructur\w+|efficiency (program|initiative)|"
117+ r"productivity (program|initiative)|streamlin\w+|optimi[sz]ation (program|initiative))"],
118+ },
119+ "partnership": {
120+ "label": "Strategic Partnership",
121+ "signals": [r"(strategic (partnership|alliance|collaboration)|joint venture|\bjv\b|"
122+ r"(partner\w+|collaborat\w+|team\w* up)[^.]{0,40}(with))"],
123+ },
124+ "technology_deployment": {
125+ "label": "Technology Deployment",
126+ "signals": [r"(deploy\w+|implement\w+|roll\w*[- ]?out|adopt\w+|install\w+)[^.]{0,50}"
127+ r"(technolog\w+|system|platform|software|solution|tool)"],
128+ },
129+}
130+
131+# ----------------------------------------------------------------------
132+# Technology vocabulary -> canonical tag
133+# ----------------------------------------------------------------------
134+TECHNOLOGY_TERMS: dict[str, str] = {
135+ r"\b(artificial intelligence|machine learning|generative ai|gen ?ai|deep learning|llm|neural)\b": "AI/ML",
136+ r"\bcloud\b": "Cloud",
137+ r"\b(erp|sap|oracle|workday|s/?4 ?hana|peoplesoft)\b": "ERP",
138+ r"\b(robot\w+|automation|cobots?)\b": "Robotics/Automation",
139+ r"\b(semiconductor|chip|wafer|fab|node\b|nanometer|foundry)\b": "Semiconductors",
140+ r"\b(solar|wind|renewable|hydrogen|geothermal)\b": "Renewable Energy",
141+ r"\b(battery|batteries|energy storage|lithium)\b": "Battery/Storage",
142+ r"\b(ev|electric vehicle|charging)\b": "Electric Vehicles",
143+ r"\b(blockchain|crypto|digital asset|tokeni[sz])\b": "Blockchain",
144+ r"\b(5g|broadband|fiber|fibre|network infrastructure)\b": "Connectivity",
145+ r"\b(data\s?cent(er|re)|hyperscale|server)\b": "Data Center",
146+ r"\b(cyber ?security|encryption|zero trust)\b": "Cybersecurity",
147+ r"\b(iot|internet of things|sensors?|edge computing)\b": "IoT/Edge",
148+ r"\b(quantum)\b": "Quantum",
149+ r"\b(biolog\w+|gene|mrna|crispr|cell therapy|molecul\w+)\b": "Biotech",
150+ r"\b(saas|software[- ]as[- ]a[- ]service|platform)\b": "Software/SaaS",
151+}
152+
153+# ----------------------------------------------------------------------
154+# Status keywords -> canonical status
155+# ----------------------------------------------------------------------
156+STATUS_TERMS: list[tuple[str, str]] = [
157+ (r"\b(completed|commission\w+|operational|in service|came online|opened|finalized)\b", "completed"),
158+ (r"\b(under construction|underway|in progress|ongoing|ramping|being (built|implemented))\b", "in_progress"),
159+ (r"\b(announc\w+|plan\w+|intend\w+|expect\w+|will (build|invest|launch|open)|propos\w+|"
160+ r"anticipat\w+|target\w+|by (20\d\d)|slated)\b", "planned"),
161+]
162+
163+# ----------------------------------------------------------------------
164+# Geography
165+# ----------------------------------------------------------------------
166+US_STATES = {
167+ "alabama","alaska","arizona","arkansas","california","colorado","connecticut","delaware",
168+ "florida","georgia","hawaii","idaho","illinois","indiana","iowa","kansas","kentucky",
169+ "louisiana","maine","maryland","massachusetts","michigan","minnesota","mississippi",
170+ "missouri","montana","nebraska","nevada","new hampshire","new jersey","new mexico",
171+ "new york","north carolina","north dakota","ohio","oklahoma","oregon","pennsylvania",
172+ "rhode island","south carolina","south dakota","tennessee","texas","utah","vermont",
173+ "virginia","washington","west virginia","wisconsin","wyoming",
174+}
175+COUNTRIES = {
176+ "united states","u.s.","usa","canada","mexico","china","india","japan","germany","france",
177+ "united kingdom","u.k.","ireland","brazil","singapore","south korea","korea","taiwan",
178+ "vietnam","thailand","malaysia","indonesia","australia","spain","italy","netherlands",
179+ "switzerland","sweden","poland","israel","saudi arabia","united arab emirates","uae",
180+ "philippines","czech republic","hungary","turkey","egypt","south africa","argentina","chile",
181+}
182+
183+_MONEY_RE = re.compile(
184+ r"(?:US)?\$\s?(\d[\d,]*\.?\d*)\s?(billion|bn|million|mm|mn|m\b|thousand|k\b|trillion)?",
185+ re.IGNORECASE,
186+)
187+_MULT = {
188+ "trillion": 1e12, "billion": 1e9, "bn": 1e9, "million": 1e6, "mm": 1e6, "mn": 1e6,
189+ "m": 1e6, "thousand": 1e3, "k": 1e3,
190+}
191+_YEAR_RE = re.compile(r"\b(?:fiscal\s+|fy\s?)?(20[0-3]\d|19[89]\d)\b", re.IGNORECASE)
192+_QUARTER_RE = re.compile(r"\bQ([1-4])\s?(20\d\d)\b", re.IGNORECASE)
193+_TECH_COMPILED = [(re.compile(p, re.IGNORECASE), tag) for p, tag in TECHNOLOGY_TERMS.items()]
194+_STATUS_COMPILED = [(re.compile(p, re.IGNORECASE), s) for p, s in STATUS_TERMS]
195+_CITY_STATE_RE = re.compile(r"\bin\s+([A-Z][a-zA-Z.]+(?:\s[A-Z][a-zA-Z.]+){0,2}),\s+([A-Z][a-zA-Z]+)")
196+
197+
198+def extract_money(text: str) -> tuple[float | None, str | None]:
199+ """Return (usd_amount, raw_span) for the largest explicit dollar figure."""
200+ best_val, best_raw = None, None
201+ for m in _MONEY_RE.finditer(text):
202+ try:
203+ num = float(m.group(1).replace(",", ""))
204+ except ValueError:
205+ continue
206+ unit = (m.group(2) or "").lower().strip()
207+ val = num * _MULT.get(unit, 1.0)
208+ if best_val is None or val > best_val:
209+ best_val, best_raw = val, m.group(0).strip()
210+ return best_val, best_raw
211+
212+
213+def extract_locations(text: str) -> list[str]:
214+ low = text.lower()
215+ found: list[str] = []
216+ for st in US_STATES:
217+ if re.search(rf"\b{re.escape(st)}\b", low):
218+ found.append(st.title())
219+ for co in COUNTRIES:
220+ if re.search(rf"\b{re.escape(co)}\b", low):
221+ found.append(co.upper() if len(co) <= 4 else co.title())
222+ for m in _CITY_STATE_RE.finditer(text):
223+ found.append(f"{m.group(1)}, {m.group(2)}")
224+ # de-dup preserving order
225+ seen, out = set(), []
226+ for f in found:
227+ k = f.lower()
228+ if k not in seen:
229+ seen.add(k); out.append(f)
230+ return out[:6]
231+
232+
233+def extract_years(text: str) -> list[int]:
234+ yrs = {int(y) for y in _YEAR_RE.findall(text)}
235+ for m in _QUARTER_RE.finditer(text):
236+ yrs.add(int(m.group(2)))
237+ return sorted(yrs)
238+
239+
240+def extract_technologies(text: str) -> list[str]:
241+ tags = []
242+ for rx, tag in _TECH_COMPILED:
243+ if rx.search(text) and tag not in tags:
244+ tags.append(tag)
245+ return tags
246+
247+
248+def detect_status(text: str) -> str:
249+ for rx, status in _STATUS_COMPILED:
250+ if rx.search(text):
251+ return status
252+ return "mentioned"
253+
254+
255+# pre-compile project-type signals
256+TYPE_SIGNALS = {
257+ ptype: [re.compile(sig, re.IGNORECASE) for sig in spec["signals"]]
258+ for ptype, spec in PROJECT_TYPES.items()
259+}
260+TYPE_LABELS = {k: v["label"] for k, v in PROJECT_TYPES.items()}
261+ALL_TYPES = list(PROJECT_TYPES.keys())
added code/audit/spid_snapshot/SHA256SUMS +9 −0
@@ -0,0 +1,9 @@
1+4ba1811f9513ab472855764ba8d09363060fed6e7d009f68f5b2749e85833ab1 worktrees/FIRC-0001/code/audit/spid_snapshot/base.py
2+8c4a273631e0015867a1c88d92e284faca906ea16e9af2595dfa5cb06d5b8d06 worktrees/FIRC-0001/code/audit/spid_snapshot/config.py
3+c53536becfe273f03882c309ba6ac79d4423c5bf1320865d044a0d00effada90 worktrees/FIRC-0001/code/audit/spid_snapshot/ingest.py
4+ae7f3f61a8de36842ba8c99a566883465fe8d6672ec723ee438f963e50d4eb89 worktrees/FIRC-0001/code/audit/spid_snapshot/llm.py
5+6135f39adb9e78b7e50b81a9034db07e45f30fcb3a329df5c6c9a79bad028712 worktrees/FIRC-0001/code/audit/spid_snapshot/pipeline.py
6+925ab1258b2a364f1c9ad5be5817b4d815fe11e7fca5ad32f3ac71b5be59652d worktrees/FIRC-0001/code/audit/spid_snapshot/resolve.py
7+7f191c18b0426d83931fa6f1f461f3a2d43bcc1d151a14a6e084f24db6766807 worktrees/FIRC-0001/code/audit/spid_snapshot/rules.py
8+1219c923fff72b9946bc068baacd70802fd5fab86766e62141dbdd2ea066a54f worktrees/FIRC-0001/code/audit/spid_snapshot/store.py
9+92ad69bcc4e448d03496943221fdcb78b4326baa7db682a6b6713c260c9c4e7a worktrees/FIRC-0001/code/audit/spid_snapshot/taxonomy.py
added code/audit/spid_snapshot/base.py +69 −0
@@ -0,0 +1,69 @@
1+# ============================================================
2+# SPID — extraction interface & record schema
3+# ============================================================
4+from __future__ import annotations
5+
6+import hashlib
7+from dataclasses import dataclass, field, asdict
8+from typing import Protocol
9+
10+
11+@dataclass
12+class ProjectMention:
13+ """A single project signal extracted from one filing section.
14+
15+ Mentions are the atomic unit; `resolve.py` merges many mentions
16+ (across filings & years) into a single Project with a timeline.
17+ """
18+ mention_id: str
19+ accession_number: str
20+ cik: str
21+ section_id: str
22+ section_name: str
23+
24+ project_name: str
25+ project_type: str
26+ description: str
27+ objective: str = ""
28+
29+ amount_usd: float | None = None
30+ amount_raw: str | None = None
31+ locations: list[str] = field(default_factory=list)
32+ technologies: list[str] = field(default_factory=list)
33+ partners: list[str] = field(default_factory=list)
34+ suppliers: list[str] = field(default_factory=list)
35+ benefits: list[str] = field(default_factory=list)
36+ risks: list[str] = field(default_factory=list)
37+ years: list[int] = field(default_factory=list)
38+ status: str = "mentioned"
39+
40+ confidence: float = 0.0
41+ backend: str = "rules"
42+
43+ def to_row(self) -> dict:
44+ d = asdict(self)
45+ # store list fields as JSON-friendly via duckdb list[str]
46+ return d
47+
48+ @staticmethod
49+ def make_id(accession: str, section_id: str, ptype: str, anchor: str) -> str:
50+ h = hashlib.sha1(f"{accession}|{section_id}|{ptype}|{anchor}".encode()).hexdigest()
51+ return h[:20]
52+
53+
54+class Extractor(Protocol):
55+ name: str
56+
57+ def extract(self, *, text: str, accession_number: str, cik: str,
58+ section_id: str, section_name: str) -> list[ProjectMention]:
59+ ...
60+
61+
62+def get_extractor(backend: str):
63+ """Factory. `backend` in {'rules','llm'}."""
64+ backend = (backend or "rules").lower()
65+ if backend == "llm":
66+ from spid.extract.llm import LLMExtractor
67+ return LLMExtractor()
68+ from spid.extract.rules import RuleExtractor
69+ return RuleExtractor()
added code/audit/spid_snapshot/config.py +126 −0
@@ -0,0 +1,126 @@
1+# ============================================================
2+# SPID — configuration & paths
3+# ============================================================
4+from __future__ import annotations
5+
6+import logging
7+import os
8+from dataclasses import dataclass, field
9+from functools import lru_cache
10+from pathlib import Path
11+
12+# project-db/ (this file lives in project-db/spid/config.py)
13+PROJECT_ROOT = Path(__file__).resolve().parent.parent
14+
15+
16+def _load_dotenv() -> None:
17+ """Minimal .env loader (no python-dotenv dependency required)."""
18+ env_path = PROJECT_ROOT / ".env"
19+ if not env_path.exists():
20+ return
21+ for line in env_path.read_text().splitlines():
22+ line = line.strip()
23+ if not line or line.startswith("#") or "=" not in line:
24+ continue
25+ key, _, val = line.partition("=")
26+ key, val = key.strip(), val.strip().strip('"').strip("'")
27+ os.environ.setdefault(key, val)
28+
29+
30+_load_dotenv()
31+# The parent UQO_RESEARCH_DB that holds the parsed corpus.
32+RESEARCH_ROOT = PROJECT_ROOT.parent
33+SOURCE_DATA = RESEARCH_ROOT / "data"
34+
35+
36+def _parse_scope(spec: str) -> dict:
37+ out: dict = {}
38+ for part in (spec or "").split(";"):
39+ part = part.strip()
40+ if not part or ":" not in part:
41+ continue
42+ form, items = part.split(":", 1)
43+ out[form.strip()] = [i.strip() for i in items.split(",") if i.strip()]
44+ return out
45+
46+
47+@dataclass(frozen=True)
48+class Settings:
49+ """Resolved runtime configuration for the SPID platform."""
50+
51+ # --- source corpus (read-only) -------------------------------------
52+ source_data: Path = SOURCE_DATA
53+ parquet_dir: Path = SOURCE_DATA / "parquet"
54+ processed_dir: Path = SOURCE_DATA / "processed"
55+
56+ @property
57+ def filings_parquet(self) -> Path:
58+ return self.parquet_dir / "filings" / "filings.parquet"
59+
60+ @property
61+ def sections_parquet(self) -> Path:
62+ return self.parquet_dir / "filing_sections" / "filing_sections.parquet"
63+
64+ @property
65+ def companies_parquet(self) -> Path:
66+ return self.parquet_dir / "companies" / "companies.parquet"
67+
68+ # --- SPID outputs (writable) ---------------------------------------
69+ db_path: Path = PROJECT_ROOT / "data" / "db" / "spid.duckdb"
70+ out_dir: Path = PROJECT_ROOT / "data"
71+
72+ # --- extraction ----------------------------------------------------
73+ backend: str = field(
74+ default_factory=lambda: os.environ.get("SPID_BACKEND", "llm")
75+ ) # "llm" (gpt-4o-mini) | "rules"
76+ min_confidence: float = 0.45
77+ # which parsed sections feed the extractor, per form (None/empty = every section)
78+ section_scope: dict = field(default_factory=lambda: _parse_scope(os.environ.get(
79+ "SPID_SECTION_SCOPE",
80+ "10-K:business,risk_factors,properties,md_and_a,quantitative_disclosures")))
81+ max_section_chars: int = 600_000
82+
83+ # --- embeddings ----------------------------------------------------
84+ embedding_model: str = field(
85+ default_factory=lambda: os.environ.get("SPID_EMBED_MODEL", "all-MiniLM-L6-v2")
86+ )
87+
88+ # --- LLM backend (OpenAI-compatible) -------------------------------
89+ llm_base_url: str = field(
90+ default_factory=lambda: os.environ.get(
91+ "SPID_LLM_BASE_URL", "https://api.openai.com/v1"
92+ )
93+ )
94+ llm_model: str = field(
95+ default_factory=lambda: os.environ.get("SPID_LLM_MODEL", "gpt-4o-mini")
96+ )
97+ llm_api_key: str = field(
98+ default_factory=lambda: os.environ.get("OPENAI_API_KEY", "")
99+ or os.environ.get("MACLUSTR_API_KEY", "")
100+ )
101+ llm_timeout: float = 120.0
102+
103+ def ensure_dirs(self) -> None:
104+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
105+ (self.out_dir / "exports").mkdir(parents=True, exist_ok=True)
106+
107+
108+@lru_cache(maxsize=1)
109+def get_settings() -> Settings:
110+ s = Settings()
111+ s.ensure_dirs()
112+ return s
113+
114+
115+def get_logger(name: str) -> logging.Logger:
116+ logger = logging.getLogger(f"spid.{name}")
117+ if not logger.handlers:
118+ h = logging.StreamHandler()
119+ h.setFormatter(
120+ logging.Formatter("%(asctime)s %(levelname)-7s %(name)s | %(message)s",
121+ datefmt="%H:%M:%S")
122+ )
123+ logger.addHandler(h)
124+ logger.setLevel(os.environ.get("SPID_LOG_LEVEL", "INFO"))
125+ logger.propagate = False
126+ return logger
added code/audit/spid_snapshot/ingest.py +135 −0
@@ -0,0 +1,135 @@
1+# ============================================================
2+# SPID — raw 10-K ingester
3+#
4+# The pre-parsed corpus only sectioned 10-Q & 8-K filings; the
5+# 10-K HTML was left raw. This module cleans the raw 10-K HTML
6+# and extracts the project-dense Items (1 Business, 1A Risk,
7+# 7 MD&A, 2 Properties) into a SPID-owned `spid_sections` table
8+# that `v_sections` unions in — so 10-Ks flow through the same
9+# extraction pipeline.
10+# ============================================================
11+from __future__ import annotations
12+
13+import glob
14+import re
15+import uuid
16+from pathlib import Path
17+
18+from spid.config import get_logger, get_settings
19+from spid.store import Store
20+
21+log = get_logger("ingest")
22+
23+RAW_SEC = get_settings().source_data / "raw" / "sec"
24+
25+# 10-K items we keep (where projects/capex/strategy concentrate)
26+_KEEP_ITEMS = {
27+ "1": "business", "1A": "risk_factors", "2": "properties",
28+ "7": "md_and_a", "7A": "quantitative_disclosures",
29+}
30+_ITEM_RE = re.compile(r"(?:^|\n)\s*ITEM\s+(\d+[AB]?)\s*[.\-:—–]", re.IGNORECASE)
31+_TAG_RE = re.compile(r"<[^>]+>")
32+_WS_RE = re.compile(r"[ \t]+")
33+_NL_RE = re.compile(r"\n{3,}")
34+
35+
36+def _clean_html(path: Path) -> str:
37+ raw = path.read_text(errors="ignore")
38+ try:
39+ from bs4 import BeautifulSoup
40+ soup = BeautifulSoup(raw, "lxml")
41+ for t in soup(["script", "style", "table"]):
42+ t.decompose()
43+ text = soup.get_text("\n")
44+ except Exception: # noqa: BLE001
45+ text = _TAG_RE.sub(" ", raw)
46+ import html as _html
47+ text = _html.unescape(text)
48+ text = _WS_RE.sub(" ", text)
49+ text = _NL_RE.sub("\n\n", text)
50+ return text
51+
52+
53+def _extract_items(text: str) -> list[tuple[str, str]]:
54+ matches = list(_ITEM_RE.finditer(text))
55+ if not matches:
56+ return [("full_body", text)]
57+ out: list[tuple[str, str]] = []
58+ for i, m in enumerate(matches):
59+ item = m.group(1).upper()
60+ if item not in _KEEP_ITEMS:
61+ continue
62+ start = m.start()
63+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
64+ body = text[start:end].strip()
65+ if len(body) > 400: # skip the table-of-contents stubs
66+ out.append((_KEEP_ITEMS[item], body[:600_000]))
67+ # keep the longest instance of each section (TOC vs real body)
68+ best: dict[str, str] = {}
69+ for name, body in out:
70+ if name not in best or len(body) > len(best[name]):
71+ best[name] = body
72+ return list(best.items())
73+
74+
75+def _find_primary(acc: str, cik: str) -> Path | None:
76+ d = RAW_SEC / cik / "10-K" / acc.replace("-", "")
77+ if not d.is_dir():
78+ return None
79+ cands = glob.glob(str(d / "*.htm")) + glob.glob(str(d / "*.html")) + glob.glob(str(d / "*.txt"))
80+ cands = [c for c in cands if "R" + "" not in Path(c).name or True]
81+ if not cands:
82+ return None
83+ return Path(max(cands, key=lambda p: Path(p).stat().st_size))
84+
85+
86+_SPID_SECTIONS_DDL = """
87+CREATE TABLE IF NOT EXISTS spid_sections (
88+ section_id VARCHAR PRIMARY KEY,
89+ accession_number VARCHAR,
90+ section_name VARCHAR,
91+ item_number VARCHAR,
92+ text VARCHAR,
93+ word_count INTEGER
94+);
95+"""
96+
97+
98+def ingest_10k(store: Store, *, ciks: list[str] | None = None, limit: int | None = None) -> dict:
99+ con = store.con
100+ con.execute(_SPID_SECTIONS_DDL)
101+ where = "form_type='10-K'"
102+ if ciks:
103+ q = ",".join("'" + c + "'" for c in ciks)
104+ where += f" AND cik IN ({q})"
105+ sql = f"SELECT accession_number, cik FROM src_filings WHERE {where} ORDER BY filing_date DESC"
106+ if limit:
107+ sql += f" LIMIT {int(limit)}"
108+ filings = con.execute(sql).fetchall()
109+
110+ rows, done, ok = [], 0, 0
111+ for acc, cik in filings:
112+ done += 1
113+ # skip if already ingested
114+ if con.execute("SELECT 1 FROM spid_sections WHERE accession_number=? LIMIT 1", [acc]).fetchone():
115+ ok += 1
116+ continue
117+ path = _find_primary(acc, cik)
118+ if not path:
119+ continue
120+ try:
121+ text = _clean_html(path)
122+ for name, body in _extract_items(text):
123+ rows.append((str(uuid.uuid4()), acc, name, "", body, len(body.split())))
124+ ok += 1
125+ except Exception as e: # noqa: BLE001
126+ log.warning("ingest failed %s: %s", acc, e)
127+ if len(rows) >= 400:
128+ con.executemany("INSERT OR REPLACE INTO spid_sections VALUES (?,?,?,?,?,?)", rows); rows = []
129+ if done % 50 == 0:
130+ log.info(" ... ingested %d/%d 10-Ks", done, len(filings))
131+ if rows:
132+ con.executemany("INSERT OR REPLACE INTO spid_sections VALUES (?,?,?,?,?,?)", rows)
133+ n = store.count("spid_sections")
134+ log.info("Ingested 10-K sections: %d filings processed, %d section rows total.", ok, n)
135+ return {"filings_processed": ok, "section_rows": n}
added code/audit/spid_snapshot/llm.py +202 −0
@@ -0,0 +1,202 @@
1+# ============================================================
2+# SPID — LLM extractor (OpenAI-compatible; default gpt-4o-mini)
3+#
4+# Uses the chat-completions JSON mode to extract structured
5+# project records. Cost-controlled: only project-relevant
6+# windows of each section are sent to the model.
7+# ============================================================
8+from __future__ import annotations
9+
10+import json
11+import re
12+import time
13+
14+import httpx
15+
16+from spid import taxonomy as tx
17+from spid.config import get_logger, get_settings
18+from spid.extract.base import ProjectMention
19+from spid.extract.rules import _PROJECT_GATE
20+
21+log = get_logger("extract.llm")
22+
23+_SYSTEM = """You are a financial analyst extracting CORPORATE PROJECTS from SEC filing text.
24+A "project" is any concrete initiative, investment, program, expansion, construction,
25+product/R&D effort, acquisition/integration, technology deployment, partnership, or
26+strategic program the company is pursuing.
27+
28+Return STRICT JSON: {"projects": [ ... ]}. Each project object has:
29+ project_name : short descriptive name (use the official name if one is stated)
30+ project_type : EXACTLY ONE of the allowed types below
31+ description : 1 sentence, grounded in the text (no speculation)
32+ objective : the goal/why (or "")
33+ status : one of planned | in_progress | completed | mentioned
34+ amount_usd : numeric USD figure if stated (e.g. 1500000000), else null
35+ locations : array of places (city/state/country), else []
36+ technologies : array of technologies involved, else []
37+ partners : array of named partners/JV counterparties, else []
38+ benefits : array of expected benefits, else []
39+ risks : array of stated risks, else []
40+ years : array of 4-digit years mentioned, else []
41+ confidence : 0.0-1.0 (how clearly this is a real, concrete project)
42+
43+Allowed project_type values:
44+%s
45+
46+Rules:
47+- Extract ONLY real initiatives, not generic risk-factor boilerplate or hypotheticals.
48+- If the text describes no concrete project, return {"projects": []}.
49+- Do not invent facts. Numbers/locations must appear in the text.
50+- Prefer fewer high-quality projects over many vague ones.""" % "\n".join(
51+ f" - {k}: {v}" for k, v in tx.TYPE_LABELS.items()
52+)
53+
54+
55+def _focus(text: str, budget: int = 9000) -> str:
56+ """Keep only windows around project-signal hits to bound token cost."""
57+ if len(text) <= budget:
58+ return text
59+ spans: list[tuple[int, int]] = []
60+ for m in _PROJECT_GATE.finditer(text):
61+ spans.append((max(0, m.start() - 220), min(len(text), m.end() + 380)))
62+ if sum(b - a for a, b in spans) > budget * 1.4:
63+ break
64+ if not spans:
65+ return text[:budget]
66+ # merge overlapping windows
67+ spans.sort()
68+ merged = [spans[0]]
69+ for a, b in spans[1:]:
70+ if a <= merged[-1][1]:
71+ merged[-1] = (merged[-1][0], max(merged[-1][1], b))
72+ else:
73+ merged.append((a, b))
74+ chunks, total = [], 0
75+ for a, b in merged:
76+ chunks.append(text[a:b])
77+ total += b - a
78+ if total >= budget:
79+ break
80+ return "\n...\n".join(chunks)
81+
82+
83+class LLMExtractor:
84+ name = "llm"
85+
86+ def __init__(self) -> None:
87+ self.s = get_settings()
88+ if not self.s.llm_api_key:
89+ raise RuntimeError("No LLM API key configured (OPENAI_API_KEY / MACLUSTR_API_KEY).")
90+ self._client = httpx.Client(
91+ base_url=self.s.llm_base_url,
92+ headers={"Authorization": f"Bearer {self.s.llm_api_key}"},
93+ timeout=self.s.llm_timeout,
94+ )
95+
96+ def _call(self, content: str, retries: int = 3) -> dict:
97+ payload = {
98+ "model": self.s.llm_model,
99+ "messages": [
100+ {"role": "system", "content": _SYSTEM},
101+ {"role": "user", "content": content},
102+ ],
103+ "temperature": 0,
104+ "response_format": {"type": "json_object"},
105+ "max_tokens": 2000,
106+ }
107+ last = None
108+ for attempt in range(retries):
109+ try:
110+ r = self._client.post("/chat/completions", json=payload)
111+ if r.status_code == 429 or r.status_code >= 500:
112+ raise httpx.HTTPStatusError("retryable", request=r.request, response=r)
113+ r.raise_for_status()
114+ txt = r.json()["choices"][0]["message"]["content"]
115+ return json.loads(txt)
116+ except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e:
117+ last = e
118+ time.sleep(1.5 * (attempt + 1))
119+ log.warning("LLM call failed after %d retries: %s", retries, last)
120+ return {"projects": []}
121+
122+ def extract(self, *, text: str, accession_number: str, cik: str,
123+ section_id: str, section_name: str) -> list[ProjectMention]:
124+ if not text or not _PROJECT_GATE.search(text):
125+ return []
126+ content = (f"SECTION: {section_name}\n\n" + _focus(text))[:14000]
127+ data = self._call(content)
128+ out: list[ProjectMention] = []
129+ for p in data.get("projects", []) or []:
130+ if not isinstance(p, dict):
131+ continue
132+ ptype = self._norm_type(p.get("project_type", ""))
133+ desc = str(p.get("description", "")).strip()[:600]
134+ if not desc:
135+ continue
136+ amount = p.get("amount_usd")
137+ try:
138+ amount = float(amount) if amount not in (None, "", "null") else None
139+ except (TypeError, ValueError):
140+ amount = None
141+ anchor = f"{ptype}:{str(p.get('project_name',''))[:40]}"
142+ conf = p.get("confidence", 0.6)
143+ try:
144+ conf = float(conf)
145+ except (TypeError, ValueError):
146+ conf = 0.6
147+ out.append(ProjectMention(
148+ mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor),
149+ accession_number=accession_number, cik=cik,
150+ section_id=section_id, section_name=section_name,
151+ project_name=str(p.get("project_name") or tx.TYPE_LABELS.get(ptype, ptype))[:200],
152+ project_type=ptype, description=desc,
153+ objective=str(p.get("objective", ""))[:400],
154+ amount_usd=amount,
155+ amount_raw=(f"${amount:,.0f}" if amount else None),
156+ locations=_strlist(p.get("locations")),
157+ technologies=_strlist(p.get("technologies")) or tx.extract_technologies(desc),
158+ partners=_strlist(p.get("partners")),
159+ benefits=_strlist(p.get("benefits")),
160+ risks=_strlist(p.get("risks")),
161+ years=_intlist(p.get("years")) or tx.extract_years(desc),
162+ status=self._norm_status(p.get("status")),
163+ confidence=round(max(0.0, min(1.0, conf)), 3),
164+ backend=self.name,
165+ ))
166+ return out
167+
168+ @staticmethod
169+ def _norm_type(raw: str) -> str:
170+ raw = (raw or "").strip().lower().replace(" ", "_").replace("-", "_")
171+ if raw in tx.PROJECT_TYPES:
172+ return raw
173+ # fuzzy contains match against labels/keys
174+ for k, label in tx.TYPE_LABELS.items():
175+ if raw and (raw in k or raw in label.lower().replace(" ", "_")):
176+ return k
177+ return "technology_deployment"
178+
179+ @staticmethod
180+ def _norm_status(raw) -> str:
181+ raw = (str(raw or "")).strip().lower()
182+ return raw if raw in ("planned", "in_progress", "completed", "mentioned") else "mentioned"
183+
184+
185+def _strlist(v) -> list[str]:
186+ if not v:
187+ return []
188+ if isinstance(v, str):
189+ v = [v]
190+ return [str(x).strip()[:120] for x in v if str(x).strip()][:8]
191+
192+
193+def _intlist(v) -> list[int]:
194+ out = []
195+ for x in (v or []):
196+ try:
197+ y = int(str(x)[:4])
198+ if 1980 <= y <= 2040:
199+ out.append(y)
200+ except (TypeError, ValueError):
201+ continue
202+ return sorted(set(out))
added code/audit/spid_snapshot/pipeline.py +95 −0
@@ -0,0 +1,95 @@
1+# ============================================================
2+# SPID — extraction engine
3+#
4+# Iterates enriched sections, runs the chosen extractor
5+# (rules or gpt-4o-mini), filters by confidence, and loads
6+# mentions into DuckDB. LLM calls are threaded (IO-bound).
7+# ============================================================
8+from __future__ import annotations
9+
10+from concurrent.futures import ThreadPoolExecutor, as_completed
11+
12+from spid.config import get_logger, get_settings
13+from spid.extract.base import get_extractor
14+from spid.store import Store
15+
16+log = get_logger("pipeline")
17+
18+
19+def _ctx_from_rows(rows: list[dict]) -> dict:
20+ ctx = {}
21+ for r in rows:
22+ ctx.setdefault(r["accession_number"], {
23+ "ticker": r.get("ticker"),
24+ "company_name": r.get("company_name") or r.get("filing_company"),
25+ "filing_company": r.get("filing_company"),
26+ "form_type": r.get("form_type"),
27+ "filing_date": r.get("filing_date"),
28+ "fiscal_year": r.get("fiscal_year"),
29+ "sector": r.get("sector"),
30+ })
31+ return ctx
32+
33+
34+def run_extraction(store: Store, *, backend: str | None = None,
35+ ciks: list[str] | None = None, forms: list[str] | None = None,
36+ limit_sections: int | None = None, min_words: int = 30,
37+ workers: int = 8, min_confidence: float | None = None,
38+ flush_every: int = 500) -> dict:
39+ s = get_settings()
40+ backend = backend or s.backend
41+ min_conf = s.min_confidence if min_confidence is None else min_confidence
42+ extractor = get_extractor(backend)
43+
44+ rows = store.sections_for(ciks=ciks, forms=forms, limit=limit_sections, min_words=min_words)
45+ ctx = _ctx_from_rows(rows)
46+ log.info("Extracting from %d sections | backend=%s | workers=%d", len(rows), backend, workers)
47+
48+ kept = 0
49+ buf = []
50+ done_rows: list[tuple] = []
51+
52+ def work(r: dict):
53+ return extractor.extract(text=r["text"], accession_number=r["accession_number"],
54+ cik=r["cik"], section_id=r["section_id"],
55+ section_name=r["section_name"])
56+
57+ def flush():
58+ nonlocal kept, buf, done_rows
59+ if buf:
60+ kept += store.upsert_mentions(buf, ctx)
61+ buf = []
62+ if done_rows:
63+ store.mark_extracted(done_rows)
64+ done_rows = []
65+
66+ done = 0
67+ if backend == "llm" and workers > 1:
68+ with ThreadPoolExecutor(max_workers=workers) as ex:
69+ futs = {ex.submit(work, r): r for r in rows}
70+ for fut in as_completed(futs):
71+ done += 1
72+ r = futs[fut]
73+ try:
74+ ms = [m for m in fut.result() if m.confidence >= min_conf]
75+ done_rows.append((r["section_id"], r["accession_number"], backend, len(ms)))
76+ except Exception as e: # noqa: BLE001
77+ log.warning("section failed: %s", e); ms = []
78+ buf.extend(ms)
79+ if len(buf) >= flush_every:
80+ flush()
81+ if done % 200 == 0:
82+ log.info(" ... %d/%d sections, %d mentions kept", done, len(rows), kept + len(buf))
83+ else:
84+ for r in rows:
85+ done += 1
86+ ms = [m for m in work(r) if m.confidence >= min_conf]
87+ done_rows.append((r["section_id"], r["accession_number"], backend, len(ms)))
88+ buf.extend(ms)
89+ if len(buf) >= flush_every:
90+ flush()
91+ if done % 2000 == 0:
92+ log.info(" ... %d/%d sections, %d mentions kept", done, len(rows), kept + len(buf))
93+ flush()
94+ log.info("Extraction complete: %d mentions from %d sections.", kept, len(rows))
95+ return {"sections": len(rows), "mentions": kept, "backend": backend}
added code/audit/spid_snapshot/resolve.py +75 −0
@@ -0,0 +1,75 @@
1+# ============================================================
2+# SPID — project resolution & timelines
3+#
4+# Merges many mentions (across filings/years) into a single
5+# Project record + a chronological timeline. Pure DuckDB SQL
6+# so it scales to millions of mentions.
7+# ============================================================
8+from __future__ import annotations
9+
10+from spid.config import get_logger
11+from spid.store import Store
12+
13+log = get_logger("resolve")
14+
15+# Grouping key: a project is identified by (company, type, anchor)
16+# where anchor = first location if present, else first salient name token.
17+_BUILD_PROJECTS = """
18+CREATE OR REPLACE TEMP TABLE _m AS
19+SELECT *,
20+ lower(coalesce(try(locations[1]), '')) AS loc1,
21+ regexp_extract(
22+ lower(regexp_replace(project_name, '[^A-Za-z ]', '', 'g')),
23+ '([a-z]{4,})', 1) AS name_tok
24+FROM project_mentions;
25+
26+CREATE OR REPLACE TEMP TABLE _keyed AS
27+SELECT *,
28+ cik || '|' || project_type || '|' ||
29+ CASE WHEN loc1 <> '' THEN loc1 ELSE coalesce(nullif(name_tok,''),'general') END AS gkey,
30+ md5(cik || '|' || project_type || '|' ||
31+ CASE WHEN loc1 <> '' THEN loc1 ELSE coalesce(nullif(name_tok,''),'general') END) AS project_id
32+FROM _m;
33+
34+DELETE FROM projects;
35+INSERT INTO projects
36+SELECT
37+ project_id,
38+ any_value(cik) AS cik,
39+ any_value(ticker) AS ticker,
40+ any_value(company_name) AS company_name,
41+ any_value(sector) AS sector,
42+ any_value(project_type) AS project_type,
43+ -- representative name: the highest-confidence mention's name
44+ arg_max(project_name, confidence) AS project_name,
45+ nullif(any_value(loc1), '') AS canonical_location,
46+ list_distinct(flatten(list(technologies))) AS technologies,
47+ -- latest-by-date status
48+ arg_max(status, filing_date) AS status,
49+ max(amount_usd) AS total_amount_usd,
50+ count(*) AS n_mentions,
51+ count(DISTINCT accession_number) AS n_filings,
52+ min(filing_date) AS first_seen,
53+ max(filing_date) AS last_seen,
54+ min(list_min(years)) AS first_year,
55+ max(list_max(years)) AS last_year,
56+ round(avg(confidence), 3) AS avg_confidence,
57+ arg_max(description, confidence) AS description
58+FROM _keyed
59+GROUP BY project_id;
60+
61+DELETE FROM project_timeline;
62+INSERT INTO project_timeline
63+SELECT project_id, accession_number, filing_date, form_type, status, amount_usd,
64+ confidence, left(description, 300) AS snippet
65+FROM _keyed
66+ORDER BY project_id, filing_date;
67+"""
68+
69+
70+def resolve_projects(store: Store) -> dict:
71+ store.con.execute(_BUILD_PROJECTS)
72+ n_proj = store.count("projects")
73+ n_tl = store.count("project_timeline")
74+ log.info("Resolved %d projects from mentions (%d timeline rows).", n_proj, n_tl)
75+ return {"projects": n_proj, "timeline_rows": n_tl}
added code/audit/spid_snapshot/rules.py +120 −0
@@ -0,0 +1,120 @@
1+# ============================================================
2+# SPID — deterministic rule-based extractor
3+#
4+# High-precision baseline that needs NO LLM. Scans section text
5+# sentence-by-sentence for project signals, classifies the type,
6+# and pulls money / location / tech / year / status.
7+# ============================================================
8+from __future__ import annotations
9+
10+import re
11+
12+from spid import taxonomy as tx
13+from spid.extract.base import ProjectMention
14+
15+# split into sentence-ish units; keep it cheap for 100k+ sections
16+_SENT_SPLIT = re.compile(r"(?<=[.;:!?])\s+(?=[A-Z(\"$])")
17+_WS = re.compile(r"\s+")
18+# generic "project-ish" gate so we skip boilerplate sentences fast
19+_PROJECT_GATE = re.compile(
20+ r"\b(project|initiative|program|invest|expan|construct|build|launch|develop|"
21+ r"deploy|implement|acqui|partner|facilit|capacity|capital|migrat|transform|"
22+ r"pipeline|plant|data ?cent|automat|sustainab|renewable|restructur)\w*",
23+ re.IGNORECASE,
24+)
25+_NAMED_PROJECT = re.compile(
26+ r"\b((?:Project|Program|Initiative)\s+[A-Z][A-Za-z0-9\-]+"
27+ r"|[A-Z][A-Za-z0-9\-]+\s+(?:Project|Program|Initiative))\b"
28+)
29+
30+
31+def _clean(s: str) -> str:
32+ return _WS.sub(" ", s).strip()
33+
34+
35+class RuleExtractor:
36+ name = "rules"
37+
38+ def extract(self, *, text: str, accession_number: str, cik: str,
39+ section_id: str, section_name: str) -> list[ProjectMention]:
40+ if not text:
41+ return []
42+ text = text[:600_000]
43+ out: list[ProjectMention] = []
44+ seen: set[str] = set()
45+
46+ for raw_sent in _SENT_SPLIT.split(text):
47+ sent = _clean(raw_sent)
48+ if not (20 <= len(sent) <= 700):
49+ continue
50+ if not _PROJECT_GATE.search(sent):
51+ continue
52+
53+ low = sent.lower()
54+ matched_types: list[str] = []
55+ for ptype, regexes in tx.TYPE_SIGNALS.items():
56+ if any(rx.search(low) for rx in regexes):
57+ matched_types.append(ptype)
58+ if not matched_types:
59+ continue
60+
61+ amount, amount_raw = tx.extract_money(sent)
62+ locations = tx.extract_locations(sent)
63+ techs = tx.extract_technologies(sent)
64+ years = tx.extract_years(sent)
65+ status = tx.detect_status(sent)
66+ nm = _NAMED_PROJECT.search(sent)
67+
68+ # keep at most the 2 strongest types for one sentence
69+ for ptype in matched_types[:2]:
70+ anchor = f"{ptype}:{(locations[0] if locations else '')}:{int(amount or 0)}"
71+ if anchor in seen:
72+ continue
73+ seen.add(anchor)
74+
75+ conf = self._confidence(ptype, amount, locations, years, status, nm, low)
76+ if conf < 0.30:
77+ continue
78+
79+ pname = nm.group(1) if nm else self._synth_name(ptype, locations, techs)
80+ out.append(ProjectMention(
81+ mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor),
82+ accession_number=accession_number, cik=cik,
83+ section_id=section_id, section_name=section_name,
84+ project_name=pname, project_type=ptype,
85+ description=sent[:500],
86+ amount_usd=amount, amount_raw=amount_raw,
87+ locations=locations, technologies=techs, years=years,
88+ status=status, confidence=round(conf, 3), backend=self.name,
89+ ))
90+ return out
91+
92+ @staticmethod
93+ def _synth_name(ptype: str, locations: list[str], techs: list[str]) -> str:
94+ label = tx.TYPE_LABELS.get(ptype, ptype)
95+ if locations:
96+ return f"{label} — {locations[0]}"
97+ if techs:
98+ return f"{label} ({techs[0]})"
99+ return label
100+
101+ @staticmethod
102+ def _confidence(ptype, amount, locations, years, status, nm, low) -> float:
103+ c = 0.40
104+ if nm:
105+ c += 0.20
106+ if amount:
107+ c += 0.18
108+ if locations:
109+ c += 0.10
110+ if years:
111+ c += 0.06
112+ if status in ("planned", "in_progress", "completed"):
113+ c += 0.08
114+ # strong, specific phrasings
115+ if re.search(r"\b(announced|committed|invest\w+|will (build|open|invest))\b", low):
116+ c += 0.06
117+ # de-weight vague risk-factor / forward-looking hedges
118+ if re.search(r"\b(may|might|could|potential\w*|if we|uncertain)\b", low):
119+ c -= 0.08
120+ return max(0.0, min(1.0, c))
added code/audit/spid_snapshot/store.py +239 −0
@@ -0,0 +1,239 @@
1+# ============================================================
2+# SPID — DuckDB store
3+#
4+# Source corpus (filings / sections / companies) is exposed as
5+# read-only views over the existing parquet. SPID writes its
6+# own tables: mentions, projects, timeline, graph, embeddings.
7+# ============================================================
8+from __future__ import annotations
9+
10+from pathlib import Path
11+
12+import duckdb
13+
14+from spid.config import Settings, get_settings
15+from spid.extract.base import ProjectMention
16+
17+SCHEMA = """
18+CREATE TABLE IF NOT EXISTS project_mentions (
19+ mention_id VARCHAR PRIMARY KEY,
20+ accession_number VARCHAR,
21+ cik VARCHAR,
22+ section_id VARCHAR,
23+ section_name VARCHAR,
24+ project_name VARCHAR,
25+ project_type VARCHAR,
26+ description VARCHAR,
27+ objective VARCHAR,
28+ amount_usd DOUBLE,
29+ amount_raw VARCHAR,
30+ locations VARCHAR[],
31+ technologies VARCHAR[],
32+ partners VARCHAR[],
33+ suppliers VARCHAR[],
34+ benefits VARCHAR[],
35+ risks VARCHAR[],
36+ years INTEGER[],
37+ status VARCHAR,
38+ confidence DOUBLE,
39+ backend VARCHAR,
40+ -- denormalised filing context (filled on load)
41+ ticker VARCHAR,
42+ company_name VARCHAR,
43+ form_type VARCHAR,
44+ filing_date DATE,
45+ fiscal_year INTEGER,
46+ sector VARCHAR
47+);
48+
49+CREATE TABLE IF NOT EXISTS projects (
50+ project_id VARCHAR PRIMARY KEY,
51+ cik VARCHAR,
52+ ticker VARCHAR,
53+ company_name VARCHAR,
54+ sector VARCHAR,
55+ project_type VARCHAR,
56+ project_name VARCHAR,
57+ canonical_location VARCHAR,
58+ technologies VARCHAR[],
59+ status VARCHAR,
60+ total_amount_usd DOUBLE,
61+ n_mentions INTEGER,
62+ n_filings INTEGER,
63+ first_seen DATE,
64+ last_seen DATE,
65+ first_year INTEGER,
66+ last_year INTEGER,
67+ avg_confidence DOUBLE,
68+ description VARCHAR
69+);
70+
71+CREATE TABLE IF NOT EXISTS project_timeline (
72+ project_id VARCHAR,
73+ accession_number VARCHAR,
74+ filing_date DATE,
75+ form_type VARCHAR,
76+ status VARCHAR,
77+ amount_usd DOUBLE,
78+ confidence DOUBLE,
79+ snippet VARCHAR
80+);
81+
82+CREATE TABLE IF NOT EXISTS kg_nodes (
83+ node_id VARCHAR PRIMARY KEY,
84+ node_type VARCHAR, -- Company | Project | Technology | Location | Partner
85+ label VARCHAR,
86+ props JSON
87+);
88+
89+CREATE TABLE IF NOT EXISTS kg_edges (
90+ src VARCHAR,
91+ rel VARCHAR, -- owns | uses | located_in | partners_with | requires_capital
92+ dst VARCHAR,
93+ props JSON
94+);
95+
96+CREATE TABLE IF NOT EXISTS project_embeddings (
97+ project_id VARCHAR PRIMARY KEY,
98+ model VARCHAR,
99+ dim INTEGER,
100+ vector FLOAT[]
101+);
102+"""
103+
104+
105+class Store:
106+ def __init__(self, settings: Settings | None = None, read_only: bool = False):
107+ self.s = settings or get_settings()
108+ self.s.db_path.parent.mkdir(parents=True, exist_ok=True)
109+ self.con = duckdb.connect(str(self.s.db_path), read_only=read_only)
110+ if not read_only:
111+ self._init()
112+
113+ def _init(self) -> None:
114+ self.con.execute(
115+ "CREATE TABLE IF NOT EXISTS spid_sections ("
116+ "section_id VARCHAR PRIMARY KEY, accession_number VARCHAR, section_name VARCHAR,"
117+ "item_number VARCHAR, text VARCHAR, word_count INTEGER)")
118+ self._register_sources()
119+ self.con.execute(SCHEMA)
120+
121+ def _register_sources(self) -> None:
122+ c = self.con
123+ c.execute(
124+ "CREATE TABLE IF NOT EXISTS extracted_sections ("
125+ "section_id VARCHAR PRIMARY KEY, accession_number VARCHAR, backend VARCHAR,"
126+ "n_mentions INTEGER, extracted_at TIMESTAMP DEFAULT current_timestamp)")
127+ c.execute(f"CREATE OR REPLACE VIEW src_filings AS SELECT * FROM read_parquet('{self.s.filings_parquet}')")
128+ c.execute(f"CREATE OR REPLACE VIEW src_sections AS SELECT * FROM read_parquet('{self.s.sections_parquet}')")
129+ c.execute(f"CREATE OR REPLACE VIEW src_companies AS SELECT * FROM read_parquet('{self.s.companies_parquet}')")
130+ # unified, enriched section feed: pre-parsed sections (all forms since 2026-10-27)
131+ # UNION ingested 10-K items (legacy S&P 500 run); a 10-K already ingested in
132+ # spid_sections is not read a second time from the parsed corpus.
133+ scope = self.s.section_scope
134+ scope_sql = ""
135+ if scope:
136+ parts = []
137+ for form, items in scope.items():
138+ q = ",".join("'" + i + "'" for i in items)
139+ parts.append(f"(form_type = '{form}' AND section_name IN ({q}))")
140+ others = ",".join("'" + f + "'" for f in scope)
141+ scope_sql = " WHERE " + " OR ".join(parts) + f" OR form_type NOT IN ({others})"
142+ c.execute(f"""
143+ CREATE OR REPLACE VIEW v_sections AS
144+ WITH parsed AS (
145+ SELECT s.section_id, s.accession_number, s.section_name, s.item_number, s.text, s.word_count,
146+ f.form_type
147+ FROM src_sections s JOIN src_filings f USING (accession_number)
148+ WHERE NOT (f.form_type = '10-K'
149+ AND s.accession_number IN (SELECT DISTINCT accession_number FROM spid_sections))
150+ ),
151+ all_sec AS (
152+ SELECT section_id, accession_number, section_name, item_number, text, word_count
153+ FROM parsed{scope_sql}
154+ UNION ALL
155+ SELECT section_id, accession_number, section_name, item_number, text, word_count
156+ FROM spid_sections
157+ )
158+ SELECT s.section_id, s.accession_number, s.section_name, s.item_number,
159+ s.text, s.word_count,
160+ f.cik, f.ticker, f.company_name AS filing_company, f.form_type,
161+ CAST(f.filing_date AS DATE) AS filing_date, f.fiscal_year,
162+ co.company_name AS company_name, co.sector, co.sub_industry, co.sic_code, co.state
163+ FROM all_sec s
164+ JOIN src_filings f USING (accession_number)
165+ LEFT JOIN src_companies co ON f.cik = co.cik
166+ """)
167+
168+ # ---- read side ----------------------------------------------------
169+ def companies(self) -> list[tuple]:
170+ return self.con.execute(
171+ "SELECT DISTINCT cik, ticker FROM src_filings ORDER BY cik"
172+ ).fetchall()
173+
174+ def sections_for(self, *, ciks: list[str] | None = None, forms: list[str] | None = None,
175+ limit: int | None = None, min_words: int = 30, skip_done: bool = True) -> list[dict]:
176+ where = [f"word_count >= {int(min_words)}"]
177+ if skip_done:
178+ where.append("section_id NOT IN (SELECT section_id FROM extracted_sections)")
179+ if ciks:
180+ quoted = ",".join("'" + c.replace("'", "") + "'" for c in ciks)
181+ where.append(f"cik IN ({quoted})")
182+ if forms:
183+ quoted = ",".join("'" + f.replace("'", "") + "'" for f in forms)
184+ where.append(f"form_type IN ({quoted})")
185+ sql = "SELECT * FROM v_sections WHERE " + " AND ".join(where)
186+ sql += " ORDER BY filing_date"
187+ if limit:
188+ sql += f" LIMIT {int(limit)}"
189+ res = self.con.execute(sql)
190+ table = res.to_arrow_table() if hasattr(res, "to_arrow_table") else res.fetch_arrow_table() # DuckDB >= 2.0 / < 2.0
191+ return table.to_pylist()
192+
193+ # ---- write side ---------------------------------------------------
194+ def upsert_mentions(self, mentions: list[ProjectMention], ctx: dict) -> int:
195+ """ctx maps accession_number -> filing context dict."""
196+ if not mentions:
197+ return 0
198+ rows = []
199+ for m in mentions:
200+ cx = ctx.get(m.accession_number, {})
201+ rows.append((
202+ m.mention_id, m.accession_number, m.cik, m.section_id, m.section_name,
203+ m.project_name, m.project_type, m.description, m.objective,
204+ m.amount_usd, m.amount_raw, m.locations, m.technologies, m.partners,
205+ m.suppliers, m.benefits, m.risks, m.years, m.status, m.confidence, m.backend,
206+ cx.get("ticker"), cx.get("company_name") or cx.get("filing_company"),
207+ cx.get("form_type"), cx.get("filing_date"), cx.get("fiscal_year"), cx.get("sector"),
208+ ))
209+ self.con.executemany(
210+ "INSERT OR REPLACE INTO project_mentions VALUES "
211+ "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", rows)
212+ return len(rows)
213+
214+ def mark_extracted(self, rows: list[tuple]) -> None:
215+ """rows: (section_id, accession_number, backend, n_mentions)."""
216+ if rows:
217+ self.con.executemany(
218+ "INSERT OR REPLACE INTO extracted_sections (section_id, accession_number, backend, n_mentions) "
219+ "VALUES (?,?,?,?)", rows)
220+
221+ def backfill_extracted(self, backend: str = "llm") -> int:
222+ """Record every section currently visible in v_sections as already extracted.
223+
224+ Used once, before the Russell 3000 corpus is merged, so the legacy S&P 500 run
225+ (whose sections were processed without a ledger) is not re-billed.
226+ """
227+ self.con.execute(f"""
228+ INSERT OR REPLACE INTO extracted_sections (section_id, accession_number, backend, n_mentions)
229+ SELECT v.section_id, v.accession_number, '{backend}',
230+ (SELECT COUNT(*) FROM project_mentions m WHERE m.section_id = v.section_id)
231+ FROM v_sections v
232+ """)
233+ return self.count("extracted_sections")
234+
235+ def count(self, table: str) -> int:
236+ return self.con.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
237+
238+ def close(self) -> None:
239+ self.con.close()
added code/audit/spid_snapshot/taxonomy.py +261 −0
@@ -0,0 +1,261 @@
1+# ============================================================
2+# SPID — project taxonomy & lightweight value extractors
3+#
4+# Shared by both the rule-based and the LLM extractor so that
5+# `project_type`, `technology`, money, location and dates are
6+# normalised the same way regardless of backend.
7+# ============================================================
8+from __future__ import annotations
9+
10+import re
11+
12+# ----------------------------------------------------------------------
13+# Canonical project types. `signals` are high-precision regex fragments
14+# (already lower-cased) used by the rule extractor and as the controlled
15+# vocabulary the LLM is asked to classify into.
16+# ----------------------------------------------------------------------
17+PROJECT_TYPES: dict[str, dict] = {
18+ "manufacturing_expansion": {
19+ "label": "Manufacturing / Capacity Expansion",
20+ "signals": [
21+ r"(expand|increas\w+|add\w*|ramp\w*|scal\w+|new|build\w*|construct\w*|open\w*)"
22+ r"[^.]{0,60}(manufactur\w+|production|plant|factory|facilit\w+|capacity|assembly|fab\b)",
23+ ],
24+ },
25+ "plant_construction": {
26+ "label": "Plant / Site Construction",
27+ "signals": [r"(construct\w*|build\w*|broke ground|commission\w*|new (plant|site|facility))"
28+ r"[^.]{0,60}(plant|facility|site|campus|complex|refinery|mill)"],
29+ },
30+ "data_center": {
31+ "label": "Data Center",
32+ "signals": [r"data\s?cent(er|re)s?", r"hyperscal\w+", r"server farm"],
33+ },
34+ "cloud_migration": {
35+ "label": "Cloud Migration",
36+ "signals": [r"(migrat\w+|mov\w+|transition\w*)[^.]{0,40}(cloud)",
37+ r"cloud (migration|platform|adoption|infrastructure|first)"],
38+ },
39+ "ai_initiative": {
40+ "label": "AI / ML Initiative",
41+ "signals": [r"\b(artificial intelligence|machine learning|generative ai|gen ?ai|deep learning|"
42+ r"large language model|\bllm\b|neural network)\b",
43+ r"\bai[- ](powered|driven|enabled|capabilit\w+|platform|model|tools?)\b"],
44+ },
45+ "digital_transformation": {
46+ "label": "Digital Transformation",
47+ "signals": [r"digital (transformation|transition|initiative|strateg\w+|platform)",
48+ r"digitali[sz]ation"],
49+ },
50+ "erp_implementation": {
51+ "label": "ERP / Systems Implementation",
52+ "signals": [r"\b(erp|sap|oracle|workday|s/?4 ?hana)\b[^.]{0,40}(implement\w+|deploy\w+|migrat\w+|system|platform)",
53+ r"(implement\w+|deploy\w+|upgrad\w+)[^.]{0,40}\berp\b",
54+ r"enterprise resource planning"],
55+ },
56+ "rd_program": {
57+ "label": "R&D Program",
58+ "signals": [r"research and development", r"\br&d\b",
59+ r"(develop\w+|invest\w+)[^.]{0,40}(next[- ]generation|new technolog\w+|new product)"],
60+ },
61+ "product_launch": {
62+ "label": "New Product / Launch",
63+ "signals": [r"(launch\w*|introduc\w+|releas\w+|roll\w*[- ]?out|unveil\w+)"
64+ r"[^.]{0,50}(product|platform|service|offering|solution|model|line)",
65+ r"new product (line|launch|introduction)"],
66+ },
67+ "drug_pipeline": {
68+ "label": "Drug / Clinical Pipeline",
69+ "signals": [r"(phase\s?(i{1,3}|1|2|3)\b)", r"clinical trial", r"\bfda (approval|clearance|submission)\b",
70+ r"(drug|therap\w+|candidate|molecule|pipeline)[^.]{0,40}(develop\w+|trial|approval)"],
71+ },
72+ "ma_integration": {
73+ "label": "M&A / Integration",
74+ "signals": [r"(acqui\w+|merg\w+|takeover|business combination)",
75+ r"integration of [a-z]", r"purchase of [a-z]"],
76+ },
77+ "geographic_expansion": {
78+ "label": "Geographic Expansion",
79+ "signals": [r"(expand\w*|enter\w*|launch\w*|establish\w*)[^.]{0,50}"
80+ r"(market|region|countr\w+|geograph\w+|international|overseas|abroad)",
81+ r"market entry"],
82+ },
83+ "capex_program": {
84+ "label": "Capital Expenditure Program",
85+ "signals": [r"capital (expenditure|expenditures|investment|spending|program|project|budget)",
86+ r"\bcapex\b", r"invest\w+[^.]{0,30}(\$|usd)"],
87+ },
88+ "supply_chain": {
89+ "label": "Supply Chain Initiative",
90+ "signals": [r"supply chain", r"(reshor\w+|nearshor\w+|onshor\w+|offshor\w+)",
91+ r"(distribution|logistics|fulfillment|warehouse|inventory)[^.]{0,40}"
92+ r"(network|center|optimi[sz]\w+|expansion|investment)"],
93+ },
94+ "automation": {
95+ "label": "Automation / Robotics",
96+ "signals": [r"automat\w+", r"robot\w+", r"(industrial|warehouse) automation"],
97+ },
98+ "sustainability": {
99+ "label": "Sustainability / ESG",
100+ "signals": [r"sustainabilit\w+", r"\besg\b", r"net[- ]zero", r"carbon (neutral|reduction|footprint|emissions?)",
101+ r"decarboni[sz]\w+", r"emission reduction"],
102+ },
103+ "energy_transition": {
104+ "label": "Energy Transition / Renewables",
105+ "signals": [r"renewable (energy|power)", r"(solar|wind|hydrogen|geothermal|battery storage)[^.]{0,40}"
106+ r"(project|facility|farm|plant|capacity|investment)",
107+ r"clean energy", r"energy transition"],
108+ },
109+ "infrastructure": {
110+ "label": "Infrastructure Investment",
111+ "signals": [r"infrastructure (investment|project|upgrade|expansion|spending|build\w*)",
112+ r"(network|grid|pipeline|broadband|fiber) (expansion|build\w*|upgrade|deployment)"],
113+ },
114+ "cost_reduction": {
115+ "label": "Cost Reduction / Restructuring",
116+ "signals": [r"(cost (reduction|savings|cutting)|restructur\w+|efficiency (program|initiative)|"
117+ r"productivity (program|initiative)|streamlin\w+|optimi[sz]ation (program|initiative))"],
118+ },
119+ "partnership": {
120+ "label": "Strategic Partnership",
121+ "signals": [r"(strategic (partnership|alliance|collaboration)|joint venture|\bjv\b|"
122+ r"(partner\w+|collaborat\w+|team\w* up)[^.]{0,40}(with))"],
123+ },
124+ "technology_deployment": {
125+ "label": "Technology Deployment",
126+ "signals": [r"(deploy\w+|implement\w+|roll\w*[- ]?out|adopt\w+|install\w+)[^.]{0,50}"
127+ r"(technolog\w+|system|platform|software|solution|tool)"],
128+ },
129+}
130+
131+# ----------------------------------------------------------------------
132+# Technology vocabulary -> canonical tag
133+# ----------------------------------------------------------------------
134+TECHNOLOGY_TERMS: dict[str, str] = {
135+ r"\b(artificial intelligence|machine learning|generative ai|gen ?ai|deep learning|llm|neural)\b": "AI/ML",
136+ r"\bcloud\b": "Cloud",
137+ r"\b(erp|sap|oracle|workday|s/?4 ?hana|peoplesoft)\b": "ERP",
138+ r"\b(robot\w+|automation|cobots?)\b": "Robotics/Automation",
139+ r"\b(semiconductor|chip|wafer|fab|node\b|nanometer|foundry)\b": "Semiconductors",
140+ r"\b(solar|wind|renewable|hydrogen|geothermal)\b": "Renewable Energy",
141+ r"\b(battery|batteries|energy storage|lithium)\b": "Battery/Storage",
142+ r"\b(ev|electric vehicle|charging)\b": "Electric Vehicles",
143+ r"\b(blockchain|crypto|digital asset|tokeni[sz])\b": "Blockchain",
144+ r"\b(5g|broadband|fiber|fibre|network infrastructure)\b": "Connectivity",
145+ r"\b(data\s?cent(er|re)|hyperscale|server)\b": "Data Center",
146+ r"\b(cyber ?security|encryption|zero trust)\b": "Cybersecurity",
147+ r"\b(iot|internet of things|sensors?|edge computing)\b": "IoT/Edge",
148+ r"\b(quantum)\b": "Quantum",
149+ r"\b(biolog\w+|gene|mrna|crispr|cell therapy|molecul\w+)\b": "Biotech",
150+ r"\b(saas|software[- ]as[- ]a[- ]service|platform)\b": "Software/SaaS",
151+}
152+
153+# ----------------------------------------------------------------------
154+# Status keywords -> canonical status
155+# ----------------------------------------------------------------------
156+STATUS_TERMS: list[tuple[str, str]] = [
157+ (r"\b(completed|commission\w+|operational|in service|came online|opened|finalized)\b", "completed"),
158+ (r"\b(under construction|underway|in progress|ongoing|ramping|being (built|implemented))\b", "in_progress"),
159+ (r"\b(announc\w+|plan\w+|intend\w+|expect\w+|will (build|invest|launch|open)|propos\w+|"
160+ r"anticipat\w+|target\w+|by (20\d\d)|slated)\b", "planned"),
161+]
162+
163+# ----------------------------------------------------------------------
164+# Geography
165+# ----------------------------------------------------------------------
166+US_STATES = {
167+ "alabama","alaska","arizona","arkansas","california","colorado","connecticut","delaware",
168+ "florida","georgia","hawaii","idaho","illinois","indiana","iowa","kansas","kentucky",
169+ "louisiana","maine","maryland","massachusetts","michigan","minnesota","mississippi",
170+ "missouri","montana","nebraska","nevada","new hampshire","new jersey","new mexico",
171+ "new york","north carolina","north dakota","ohio","oklahoma","oregon","pennsylvania",
172+ "rhode island","south carolina","south dakota","tennessee","texas","utah","vermont",
173+ "virginia","washington","west virginia","wisconsin","wyoming",
174+}
175+COUNTRIES = {
176+ "united states","u.s.","usa","canada","mexico","china","india","japan","germany","france",
177+ "united kingdom","u.k.","ireland","brazil","singapore","south korea","korea","taiwan",
178+ "vietnam","thailand","malaysia","indonesia","australia","spain","italy","netherlands",
179+ "switzerland","sweden","poland","israel","saudi arabia","united arab emirates","uae",
180+ "philippines","czech republic","hungary","turkey","egypt","south africa","argentina","chile",
181+}
182+
183+_MONEY_RE = re.compile(
184+ r"(?:US)?\$\s?(\d[\d,]*\.?\d*)\s?(billion|bn|million|mm|mn|m\b|thousand|k\b|trillion)?",
185+ re.IGNORECASE,
186+)
187+_MULT = {
188+ "trillion": 1e12, "billion": 1e9, "bn": 1e9, "million": 1e6, "mm": 1e6, "mn": 1e6,
189+ "m": 1e6, "thousand": 1e3, "k": 1e3,
190+}
191+_YEAR_RE = re.compile(r"\b(?:fiscal\s+|fy\s?)?(20[0-3]\d|19[89]\d)\b", re.IGNORECASE)
192+_QUARTER_RE = re.compile(r"\bQ([1-4])\s?(20\d\d)\b", re.IGNORECASE)
193+_TECH_COMPILED = [(re.compile(p, re.IGNORECASE), tag) for p, tag in TECHNOLOGY_TERMS.items()]
194+_STATUS_COMPILED = [(re.compile(p, re.IGNORECASE), s) for p, s in STATUS_TERMS]
195+_CITY_STATE_RE = re.compile(r"\bin\s+([A-Z][a-zA-Z.]+(?:\s[A-Z][a-zA-Z.]+){0,2}),\s+([A-Z][a-zA-Z]+)")
196+
197+
198+def extract_money(text: str) -> tuple[float | None, str | None]:
199+ """Return (usd_amount, raw_span) for the largest explicit dollar figure."""
200+ best_val, best_raw = None, None
201+ for m in _MONEY_RE.finditer(text):
202+ try:
203+ num = float(m.group(1).replace(",", ""))
204+ except ValueError:
205+ continue
206+ unit = (m.group(2) or "").lower().strip()
207+ val = num * _MULT.get(unit, 1.0)
208+ if best_val is None or val > best_val:
209+ best_val, best_raw = val, m.group(0).strip()
210+ return best_val, best_raw
211+
212+
213+def extract_locations(text: str) -> list[str]:
214+ low = text.lower()
215+ found: list[str] = []
216+ for st in US_STATES:
217+ if re.search(rf"\b{re.escape(st)}\b", low):
218+ found.append(st.title())
219+ for co in COUNTRIES:
220+ if re.search(rf"\b{re.escape(co)}\b", low):
221+ found.append(co.upper() if len(co) <= 4 else co.title())
222+ for m in _CITY_STATE_RE.finditer(text):
223+ found.append(f"{m.group(1)}, {m.group(2)}")
224+ # de-dup preserving order
225+ seen, out = set(), []
226+ for f in found:
227+ k = f.lower()
228+ if k not in seen:
229+ seen.add(k); out.append(f)
230+ return out[:6]
231+
232+
233+def extract_years(text: str) -> list[int]:
234+ yrs = {int(y) for y in _YEAR_RE.findall(text)}
235+ for m in _QUARTER_RE.finditer(text):
236+ yrs.add(int(m.group(2)))
237+ return sorted(yrs)
238+
239+
240+def extract_technologies(text: str) -> list[str]:
241+ tags = []
242+ for rx, tag in _TECH_COMPILED:
243+ if rx.search(text) and tag not in tags:
244+ tags.append(tag)
245+ return tags
246+
247+
248+def detect_status(text: str) -> str:
249+ for rx, status in _STATUS_COMPILED:
250+ if rx.search(text):
251+ return status
252+ return "mentioned"
253+
254+
255+# pre-compile project-type signals
256+TYPE_SIGNALS = {
257+ ptype: [re.compile(sig, re.IGNORECASE) for sig in spec["signals"]]
258+ for ptype, spec in PROJECT_TYPES.items()
259+}
260+TYPE_LABELS = {k: v["label"] for k, v in PROJECT_TYPES.items()}
261+ALL_TYPES = list(PROJECT_TYPES.keys())
added results/n0_probe/manifest.json +27 −0
@@ -0,0 +1,27 @@
1+{
2+ "package": "n0_probe",
3+ "script": "/Users/simon-pierreboucher/firc0001_review/scripts/a00_probe_schema.py",
4+ "script_sha256": "b3bd5ca2a11782452a7d9c78b7f1ff63236fc60847ad4fc16914c1383f3b5a93",
5+ "started": "2026-11-05T14:10:08",
6+ "ended": "2026-11-05T14:10:17",
7+ "wall_seconds": 8.3,
8+ "node": "m3u96b",
9+ "python": "3.12.15",
10+ "duckdb": "2.0.0",
11+ "threads": 4,
12+ "memory_limit": "24GB",
13+ "seed": 20261105,
14+ "databases": {
15+ "/Users/simon-pierreboucher/UQO_RESEARCH_DB/project-db/data/db/spid.duckdb": {
16+ "bytes": 3155701760,
17+ "mtime": "2026-10-28T12:21:11.091389"
18+ },
19+ "/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/duckdb/edgar_research.duckdb": {
20+ "bytes": 73500995584,
21+ "mtime": "2026-10-27T21:34:46.297884"
22+ }
23+ },
24+ "read_only": true,
25+ "row_counts": {},
26+ "notes": {}
27+}
\ No newline at end of file
added results/n0_probe/schema.json +937 −0
@@ -0,0 +1,937 @@
1+{
2+ "extracted_sections": [
3+ [
4+ "section_id",
5+ "VARCHAR"
6+ ],
7+ [
8+ "accession_number",
9+ "VARCHAR"
10+ ],
11+ [
12+ "backend",
13+ "VARCHAR"
14+ ],
15+ [
16+ "n_mentions",
17+ "INTEGER"
18+ ],
19+ [
20+ "extracted_at",
21+ "TIMESTAMP"
22+ ]
23+ ],
24+ "project_mentions": [
25+ [
26+ "mention_id",
27+ "VARCHAR"
28+ ],
29+ [
30+ "accession_number",
31+ "VARCHAR"
32+ ],
33+ [
34+ "cik",
35+ "VARCHAR"
36+ ],
37+ [
38+ "section_id",
39+ "VARCHAR"
40+ ],
41+ [
42+ "section_name",
43+ "VARCHAR"
44+ ],
45+ [
46+ "project_name",
47+ "VARCHAR"
48+ ],
49+ [
50+ "project_type",
51+ "VARCHAR"
52+ ],
53+ [
54+ "description",
55+ "VARCHAR"
56+ ],
57+ [
58+ "objective",
59+ "VARCHAR"
60+ ],
61+ [
62+ "amount_usd",
63+ "DOUBLE"
64+ ],
65+ [
66+ "amount_raw",
67+ "VARCHAR"
68+ ],
69+ [
70+ "locations",
71+ "VARCHAR[]"
72+ ],
73+ [
74+ "technologies",
75+ "VARCHAR[]"
76+ ],
77+ [
78+ "partners",
79+ "VARCHAR[]"
80+ ],
81+ [
82+ "suppliers",
83+ "VARCHAR[]"
84+ ],
85+ [
86+ "benefits",
87+ "VARCHAR[]"
88+ ],
89+ [
90+ "risks",
91+ "VARCHAR[]"
92+ ],
93+ [
94+ "years",
95+ "INTEGER[]"
96+ ],
97+ [
98+ "status",
99+ "VARCHAR"
100+ ],
101+ [
102+ "confidence",
103+ "DOUBLE"
104+ ],
105+ [
106+ "backend",
107+ "VARCHAR"
108+ ],
109+ [
110+ "ticker",
111+ "VARCHAR"
112+ ],
113+ [
114+ "company_name",
115+ "VARCHAR"
116+ ],
117+ [
118+ "form_type",
119+ "VARCHAR"
120+ ],
121+ [
122+ "filing_date",
123+ "DATE"
124+ ],
125+ [
126+ "fiscal_year",
127+ "INTEGER"
128+ ],
129+ [
130+ "sector",
131+ "VARCHAR"
132+ ]
133+ ],
134+ "projects": [
135+ [
136+ "project_id",
137+ "VARCHAR"
138+ ],
139+ [
140+ "cik",
141+ "VARCHAR"
142+ ],
143+ [
144+ "ticker",
145+ "VARCHAR"
146+ ],
147+ [
148+ "company_name",
149+ "VARCHAR"
150+ ],
151+ [
152+ "sector",
153+ "VARCHAR"
154+ ],
155+ [
156+ "project_type",
157+ "VARCHAR"
158+ ],
159+ [
160+ "project_name",
161+ "VARCHAR"
162+ ],
163+ [
164+ "canonical_location",
165+ "VARCHAR"
166+ ],
167+ [
168+ "technologies",
169+ "VARCHAR[]"
170+ ],
171+ [
172+ "status",
173+ "VARCHAR"
174+ ],
175+ [
176+ "total_amount_usd",
177+ "DOUBLE"
178+ ],
179+ [
180+ "n_mentions",
181+ "INTEGER"
182+ ],
183+ [
184+ "n_filings",
185+ "INTEGER"
186+ ],
187+ [
188+ "first_seen",
189+ "DATE"
190+ ],
191+ [
192+ "last_seen",
193+ "DATE"
194+ ],
195+ [
196+ "first_year",
197+ "INTEGER"
198+ ],
199+ [
200+ "last_year",
201+ "INTEGER"
202+ ],
203+ [
204+ "avg_confidence",
205+ "DOUBLE"
206+ ],
207+ [
208+ "description",
209+ "VARCHAR"
210+ ]
211+ ],
212+ "project_timeline": [
213+ [
214+ "project_id",
215+ "VARCHAR"
216+ ],
217+ [
218+ "accession_number",
219+ "VARCHAR"
220+ ],
221+ [
222+ "filing_date",
223+ "DATE"
224+ ],
225+ [
226+ "form_type",
227+ "VARCHAR"
228+ ],
229+ [
230+ "status",
231+ "VARCHAR"
232+ ],
233+ [
234+ "amount_usd",
235+ "DOUBLE"
236+ ],
237+ [
238+ "confidence",
239+ "DOUBLE"
240+ ],
241+ [
242+ "snippet",
243+ "VARCHAR"
244+ ]
245+ ],
246+ "spid_sections": [
247+ [
248+ "section_id",
249+ "VARCHAR"
250+ ],
251+ [
252+ "accession_number",
253+ "VARCHAR"
254+ ],
255+ [
256+ "section_name",
257+ "VARCHAR"
258+ ],
259+ [
260+ "item_number",
261+ "VARCHAR"
262+ ],
263+ [
264+ "text",
265+ "VARCHAR"
266+ ],
267+ [
268+ "word_count",
269+ "INTEGER"
270+ ]
271+ ],
272+ "v_sections": [
273+ [
274+ "section_id",
275+ "VARCHAR"
276+ ],
277+ [
278+ "accession_number",
279+ "VARCHAR"
280+ ],
281+ [
282+ "section_name",
283+ "VARCHAR"
284+ ],
285+ [
286+ "item_number",
287+ "VARCHAR"
288+ ],
289+ [
290+ "text",
291+ "VARCHAR"
292+ ],
293+ [
294+ "word_count",
295+ "INTEGER"
296+ ],
297+ [
298+ "cik",
299+ "VARCHAR"
300+ ],
301+ [
302+ "ticker",
303+ "VARCHAR"
304+ ],
305+ [
306+ "filing_company",
307+ "VARCHAR"
308+ ],
309+ [
310+ "form_type",
311+ "VARCHAR"
312+ ],
313+ [
314+ "filing_date",
315+ "DATE"
316+ ],
317+ [
318+ "fiscal_year",
319+ "INTEGER"
320+ ],
321+ [
322+ "company_name",
323+ "VARCHAR"
324+ ],
325+ [
326+ "sector",
327+ "VARCHAR"
328+ ],
329+ [
330+ "sub_industry",
331+ "VARCHAR"
332+ ],
333+ [
334+ "sic_code",
335+ "VARCHAR"
336+ ],
337+ [
338+ "state",
339+ "VARCHAR"
340+ ]
341+ ],
342+ "src_filings": [
343+ [
344+ "accession_number",
345+ "VARCHAR"
346+ ],
347+ [
348+ "cik",
349+ "VARCHAR"
350+ ],
351+ [
352+ "ticker",
353+ "VARCHAR"
354+ ],
355+ [
356+ "company_name",
357+ "VARCHAR"
358+ ],
359+ [
360+ "form_type",
361+ "VARCHAR"
362+ ],
363+ [
364+ "filing_date",
365+ "DATE"
366+ ],
367+ [
368+ "report_date",
369+ "DATE"
370+ ],
371+ [
372+ "fiscal_year",
373+ "INTEGER"
374+ ],
375+ [
376+ "fiscal_quarter",
377+ "INTEGER"
378+ ],
379+ [
380+ "sec_url",
381+ "VARCHAR"
382+ ],
383+ [
384+ "local_path",
385+ "VARCHAR"
386+ ],
387+ [
388+ "primary_document",
389+ "VARCHAR"
390+ ],
391+ [
392+ "file_size_bytes",
393+ "BIGINT"
394+ ],
395+ [
396+ "download_status",
397+ "VARCHAR"
398+ ],
399+ [
400+ "parse_status",
401+ "VARCHAR"
402+ ],
403+ [
404+ "created_at",
405+ "TIMESTAMP"
406+ ],
407+ [
408+ "updated_at",
409+ "TIMESTAMP"
410+ ]
411+ ],
412+ "src_sections": [
413+ [
414+ "section_id",
415+ "VARCHAR"
416+ ],
417+ [
418+ "accession_number",
419+ "VARCHAR"
420+ ],
421+ [
422+ "section_name",
423+ "VARCHAR"
424+ ],
425+ [
426+ "item_number",
427+ "VARCHAR"
428+ ],
429+ [
430+ "section_order",
431+ "INTEGER"
432+ ],
433+ [
434+ "text",
435+ "VARCHAR"
436+ ],
437+ [
438+ "word_count",
439+ "INTEGER"
440+ ],
441+ [
442+ "char_count",
443+ "INTEGER"
444+ ],
445+ [
446+ "created_at",
447+ "TIMESTAMP"
448+ ]
449+ ],
450+ "src_companies": [
451+ [
452+ "cik",
453+ "VARCHAR"
454+ ],
455+ [
456+ "ticker",
457+ "VARCHAR"
458+ ],
459+ [
460+ "company_name",
461+ "VARCHAR"
462+ ],
463+ [
464+ "sector",
465+ "VARCHAR"
466+ ],
467+ [
468+ "sub_industry",
469+ "VARCHAR"
470+ ],
471+ [
472+ "sic_code",
473+ "VARCHAR"
474+ ],
475+ [
476+ "state",
477+ "VARCHAR"
478+ ],
479+ [
480+ "date_added",
481+ "DATE"
482+ ],
483+ [
484+ "created_at",
485+ "TIMESTAMP"
486+ ],
487+ [
488+ "updated_at",
489+ "TIMESTAMP"
490+ ],
491+ [
492+ "in_sp500",
493+ "BOOLEAN"
494+ ],
495+ [
496+ "in_russell3000",
497+ "BOOLEAN"
498+ ],
499+ [
500+ "sic_description",
501+ "VARCHAR"
502+ ],
503+ [
504+ "state_of_incorporation",
505+ "VARCHAR"
506+ ],
507+ [
508+ "exchange",
509+ "VARCHAR"
510+ ],
511+ [
512+ "fiscal_year_end",
513+ "VARCHAR"
514+ ],
515+ [
516+ "russell_weight_pct",
517+ "DOUBLE"
518+ ],
519+ [
520+ "shard_node",
521+ "VARCHAR"
522+ ]
523+ ],
524+ "views": [
525+ [
526+ "src_companies",
527+ "CREATE VIEW src_companies AS SELECT * FROM read_parquet('/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/parquet/companies/companies.parquet');"
528+ ],
529+ [
530+ "src_filings",
531+ "CREATE VIEW src_filings AS SELECT * FROM read_parquet('/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/parquet/filings/filings.parquet');"
532+ ],
533+ [
534+ "src_sections",
535+ "CREATE VIEW src_sections AS SELECT * FROM read_parquet('/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/parquet/filing_sections/filing_sections.parquet');"
536+ ],
537+ [
538+ "v_sections",
539+ "CREATE VIEW v_sections AS WITH parsed AS (SELECT s.section_id, s.accession_number, s.section_name, s.item_number, s.\"text\", s.word_count, f.form_type FROM src_sections AS s INNER JOIN src_filings AS f USING (accession_number) WHERE (NOT ((f.form_type = '10-K') AND (s.accession_number = ANY(SELECT DISTINCT accession_number FROM spid_sections))))), all_sec AS ((SELECT section_id, accession_number, section_name, item_number, \"text\", word_count FROM parsed WHERE (((form_type = '10-K') AND (section_name IN ('business', 'risk_factors', 'properties', 'md_and_a', 'quantitative_disclosures'))) OR (NOT (form_type IN ('10-K'))))) UNION ALL (SELECT section_id, accession_number, section_name, item_number, \"text\", word_count FROM spid_sections)) SELECT s.section_id, s.accession_number, s.section_name, s.item_number, s.\"text\", s.word_count, f.cik, f.ticker, f.company_name AS filing_company, f.form_type, CAST(f.filing_date AS DATE) AS filing_date, f.fiscal_year, co.company_name AS company_name, co.sector, co.sub_industry, co.sic_code, co.state FROM all_sec AS s INNER JOIN src_filings AS f USING (accession_number) LEFT JOIN src_companies AS co ON ((f.cik = co.cik));"
540+ ]
541+ ],
542+ "counts": {
543+ "extracted_sections": 1123098,
544+ "project_mentions": 308065,
545+ "projects": 121099,
546+ "project_timeline": 308065,
547+ "spid_sections": 27164
548+ },
549+ "ledger_words": [
550+ 1123098,
551+ 3605932059
552+ ],
553+ "parquet_files": [
554+ 1342283,
555+ 29.13085581
556+ ],
557+ "edgar_filings": [
558+ [
559+ "accession_number",
560+ "VARCHAR"
561+ ],
562+ [
563+ "cik",
564+ "VARCHAR"
565+ ],
566+ [
567+ "ticker",
568+ "VARCHAR"
569+ ],
570+ [
571+ "company_name",
572+ "VARCHAR"
573+ ],
574+ [
575+ "form_type",
576+ "VARCHAR"
577+ ],
578+ [
579+ "filing_date",
580+ "DATE"
581+ ],
582+ [
583+ "report_date",
584+ "DATE"
585+ ],
586+ [
587+ "fiscal_year",
588+ "INTEGER"
589+ ],
590+ [
591+ "fiscal_quarter",
592+ "INTEGER"
593+ ],
594+ [
595+ "sec_url",
596+ "VARCHAR"
597+ ],
598+ [
599+ "local_path",
600+ "VARCHAR"
601+ ],
602+ [
603+ "primary_document",
604+ "VARCHAR"
605+ ],
606+ [
607+ "file_size_bytes",
608+ "BIGINT"
609+ ],
610+ [
611+ "download_status",
612+ "VARCHAR"
613+ ],
614+ [
615+ "parse_status",
616+ "VARCHAR"
617+ ],
618+ [
619+ "created_at",
620+ "TIMESTAMP"
621+ ],
622+ [
623+ "updated_at",
624+ "TIMESTAMP"
625+ ]
626+ ],
627+ "edgar_filing_sections": [
628+ [
629+ "section_id",
630+ "VARCHAR"
631+ ],
632+ [
633+ "accession_number",
634+ "VARCHAR"
635+ ],
636+ [
637+ "section_name",
638+ "VARCHAR"
639+ ],
640+ [
641+ "item_number",
642+ "VARCHAR"
643+ ],
644+ [
645+ "section_order",
646+ "INTEGER"
647+ ],
648+ [
649+ "text",
650+ "VARCHAR"
651+ ],
652+ [
653+ "word_count",
654+ "INTEGER"
655+ ],
656+ [
657+ "char_count",
658+ "INTEGER"
659+ ],
660+ [
661+ "created_at",
662+ "TIMESTAMP"
663+ ]
664+ ],
665+ "edgar_companies": [
666+ [
667+ "cik",
668+ "VARCHAR"
669+ ],
670+ [
671+ "ticker",
672+ "VARCHAR"
673+ ],
674+ [
675+ "company_name",
676+ "VARCHAR"
677+ ],
678+ [
679+ "sector",
680+ "VARCHAR"
681+ ],
682+ [
683+ "sub_industry",
684+ "VARCHAR"
685+ ],
686+ [
687+ "sic_code",
688+ "VARCHAR"
689+ ],
690+ [
691+ "state",
692+ "VARCHAR"
693+ ],
694+ [
695+ "date_added",
696+ "DATE"
697+ ],
698+ [
699+ "created_at",
700+ "TIMESTAMP"
701+ ],
702+ [
703+ "updated_at",
704+ "TIMESTAMP"
705+ ],
706+ [
707+ "in_sp500",
708+ "BOOLEAN"
709+ ],
710+ [
711+ "in_russell3000",
712+ "BOOLEAN"
713+ ],
714+ [
715+ "sic_description",
716+ "VARCHAR"
717+ ],
718+ [
719+ "state_of_incorporation",
720+ "VARCHAR"
721+ ],
722+ [
723+ "exchange",
724+ "VARCHAR"
725+ ],
726+ [
727+ "fiscal_year_end",
728+ "VARCHAR"
729+ ],
730+ [
731+ "russell_weight_pct",
732+ "DOUBLE"
733+ ],
734+ [
735+ "shard_node",
736+ "VARCHAR"
737+ ]
738+ ],
739+ "edgar_annual_panel": [
740+ [
741+ "cik",
742+ "VARCHAR"
743+ ],
744+ [
745+ "ticker",
746+ "VARCHAR"
747+ ],
748+ [
749+ "fiscal_year",
750+ "INTEGER"
751+ ],
752+ [
753+ "filing_count",
754+ "BIGINT"
755+ ],
756+ [
757+ "total_chunks",
758+ "BIGINT"
759+ ],
760+ [
761+ "ai_adoption_mean",
762+ "DOUBLE"
763+ ],
764+ [
765+ "ai_adoption_max",
766+ "DOUBLE"
767+ ],
768+ [
769+ "ai_adoption_std",
770+ "DOUBLE"
771+ ],
772+ [
773+ "ai_adoption_top5pct",
774+ "DOUBLE"
775+ ],
776+ [
777+ "ai_adoption_wmean",
778+ "DOUBLE"
779+ ],
780+ [
781+ "ai_disruption_risk_mean",
782+ "DOUBLE"
783+ ],
784+ [
785+ "ai_disruption_risk_max",
786+ "DOUBLE"
787+ ],
788+ [
789+ "ai_disruption_risk_std",
790+ "DOUBLE"
791+ ],
792+ [
793+ "ai_disruption_risk_top5pct",
794+ "DOUBLE"
795+ ],
796+ [
797+ "ai_disruption_risk_wmean",
798+ "DOUBLE"
799+ ],
800+ [
801+ "ai_infrastructure_mean",
802+ "DOUBLE"
803+ ],
804+ [
805+ "ai_infrastructure_max",
806+ "DOUBLE"
807+ ],
808+ [
809+ "ai_infrastructure_std",
810+ "DOUBLE"
811+ ],
812+ [
813+ "ai_infrastructure_top5pct",
814+ "DOUBLE"
815+ ],
816+ [
817+ "ai_infrastructure_wmean",
818+ "DOUBLE"
819+ ],
820+ [
821+ "esg_commitment_mean",
822+ "DOUBLE"
823+ ],
824+ [
825+ "esg_commitment_max",
826+ "DOUBLE"
827+ ],
828+ [
829+ "esg_commitment_std",
830+ "DOUBLE"
831+ ],
832+ [
833+ "esg_commitment_top5pct",
834+ "DOUBLE"
835+ ],
836+ [
837+ "esg_commitment_wmean",
838+ "DOUBLE"
839+ ],
840+ [
841+ "climate_risk_mean",
842+ "DOUBLE"
843+ ],
844+ [
845+ "climate_risk_max",
846+ "DOUBLE"
847+ ],
848+ [
849+ "climate_risk_std",
850+ "DOUBLE"
851+ ],
852+ [
853+ "climate_risk_top5pct",
854+ "DOUBLE"
855+ ],
856+ [
857+ "climate_risk_wmean",
858+ "DOUBLE"
859+ ],
860+ [
861+ "cybersecurity_mean",
862+ "DOUBLE"
863+ ],
864+ [
865+ "cybersecurity_max",
866+ "DOUBLE"
867+ ],
868+ [
869+ "cybersecurity_std",
870+ "DOUBLE"
871+ ],
872+ [
873+ "cybersecurity_top5pct",
874+ "DOUBLE"
875+ ],
876+ [
877+ "cybersecurity_wmean",
878+ "DOUBLE"
879+ ],
880+ [
881+ "automation_labor_mean",
882+ "DOUBLE"
883+ ],
884+ [
885+ "automation_labor_max",
886+ "DOUBLE"
887+ ],
888+ [
889+ "automation_labor_std",
890+ "DOUBLE"
891+ ],
892+ [
893+ "automation_labor_top5pct",
894+ "DOUBLE"
895+ ],
896+ [
897+ "automation_labor_wmean",
898+ "DOUBLE"
899+ ],
900+ [
901+ "macro_risk_mean",
902+ "DOUBLE"
903+ ],
904+ [
905+ "macro_risk_max",
906+ "DOUBLE"
907+ ],
908+ [
909+ "macro_risk_std",
910+ "DOUBLE"
911+ ],
912+ [
913+ "macro_risk_top5pct",
914+ "DOUBLE"
915+ ],
916+ [
917+ "macro_risk_wmean",
918+ "DOUBLE"
919+ ]
920+ ],
921+ "edgar_tables": [
922+ "anchor_embeddings",
923+ "annual_final",
924+ "annual_panel",
925+ "chunk_scores",
926+ "companies",
927+ "embeddings_metadata",
928+ "filings",
929+ "filing_chunks",
930+ "filing_scores",
931+ "filing_sections",
932+ "processing_log",
933+ "quarterly_final",
934+ "quarterly_panel"
935+ ],
936+ "temp_ok": true
937+}
\ No newline at end of file
938
Menu

Theme