Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ jobs:
- run: pip install "httpx==0.28.1" "duckdb==1.5.4" "pytest==9.1.1"
- name: schema check (every record + every answers/ file; no live fetch)
run: python -m sourcetool check
- name: answers rules (schema, the sample check, build merge; offline)
run: python -m pytest tests/test_answers.py -q
- name: lints (kinds served minus reasoned exemptions, utc consistency, keyword baseline ratchet; offline)
run: python -m sourcetool answers-check --lint
- name: probe sources changed in this PR (fail on regressions only)
if: github.event_name == 'pull_request'
run: |
Expand Down Expand Up @@ -56,9 +56,12 @@ jobs:
run: python -m sourcetool evalres resolution_asks resolution_holdout
- name: build the DuckDB index
run: python -m sourcetool build
- name: "source lookup (development + held-out; the first source offered must be right)"
run: |
python -m sourcetool evallookup lookup_asks lookup_holdout || echo "::warning::lookup below 100%, see output"
- name: offline suite (answers rules, recorded samples, asks overlap gate, taxonomy recall, resolvers, tooling; the built pack's tests included)
run: python -m pytest tests -q --ignore=tests/test_ingest.py
- name: "source lookup, development set (the first source offered must be right; rows no offerable source can serve are reported, not counted)"
run: python -m sourcetool evallookup lookup_asks
- name: "source lookup, held-out + sealed (measured, never tuned on; held-out stood at 36/38 on 2026-10-06)"
run: python -m sourcetool evallookup lookup_holdout lookup_sealed || echo "::warning::held-out or sealed lookup below 100%, see output"
service:
name: Library API + ingest tests
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ You don't need Git or a GitHub account. Suggestions arrive through the Library A
1. Changes arrive through `sourcetool ingest`, `sourcetool harvest` or hand edits to `sources/curated/*.jsonl`.
2. Run `python -m sourcetool check` and `python -m sourcetool validate --id <id>`.
3. Open a PR. CI schema-checks every record and probes the changed ones.
4. Only the maintainer merges into `main`. A tag publishes a new signed pack.
4. Only the maintainer merges into `main`. A tag publishes a new pack; each app release pins that pack's sha256 (Ed25519 signing is planned, see `docs/PLAN.md` R12).

Please report security issues privately (see [SECURITY.md](SECURITY.md)).
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ The SmartBrain Library is an open registry of **where the data lives**: weather,

[SmartBrain](https://github.com/SecureCloudGroup/SmartBrain_3000)'s Neural Interface looks a request up in the Library **on the user's own machine**. That lookup takes milliseconds and uses no cloud search or AI service. SmartBrain then offers the best sources one at a time. When the user answers **Yes**, SmartBrain builds the card.

- **9,045 sources** (6,918 validated ok), US scope in v1:
- 269 hand-curated and validated sources from 131 providers;
- **8,672 sources** (6,531 validated ok), US scope in v1:
- 310 hand-curated sources (257 validated ok) from 140 providers, 254 of them with declared answers the app builds cards from;
- harvested from open catalogs: US open-data portals, the Mobility Database, GBFS, Census, Wikidata, APIs.guru and public-apis.
- **18 categories, 127 subcategories**, in `taxonomy/taxonomy.json`.
- **18 categories, 129 subcategories**, in `taxonomy/taxonomy.json`.
- Every source records:
- who publishes it and how official it is;
- its terms;
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@ What this repo can affect: SmartBrain installs fetch data from the sources liste
- only the maintainer merges changes;
- CI schema checks: https only, public hosts, no credentials, no undeclared parameters;
- the app runs the same validator, fetches only through its SSRF-guarded network layer, and treats every response as untrusted data;
- packs are signed and pinned on first use, and older packs are refused (no rollback).
- each app release pins one pack by tag and sha256 and installs nothing else (Ed25519 signing between app releases is planned, see `docs/PLAN.md` R12).
6 changes: 3 additions & 3 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Security:
- Submissions go into a queue (SQLite on the VPS).
- The only operator endpoint is authenticated (token held by the operator's machine) and reads that queue.

Flow: the queue feeds `sourcetool ingest` on the operator's side. It validates, aggregates votes per source, drafts a classification (R3) and opens a PR in this repo. The operator merges, a tag triggers a new signed pack, and the app fetches the manifest and installs the pack.
Flow: the queue feeds `sourcetool ingest` on the operator's side. It validates, aggregates votes per source, drafts a classification (R3) and opens a PR in this repo. The operator merges, a tag triggers a new pack release (sha256-pinned by the app; Ed25519 in v2), and the app fetches the manifest and installs the pack.

## 5. Distribution and trust

Expand All @@ -118,7 +118,7 @@ Flow: the queue feeds `sourcetool ingest` on the operator's side. It validates,

## 6. In the app (R8, R9)

- **DuckDB tables:** `library_sources`, `library_source_categories`, `library_terms`, `library_taxonomy` and `library_meta`, loaded from the signed pack. They are plaintext: public catalog data holds no user content.
- **DuckDB tables:** `library_sources`, `library_source_categories`, `library_terms`, `library_taxonomy` and `library_meta`, loaded from the pinned pack. They are plaintext: public catalog data holds no user content.
- **Local sources** are user data. They are sealed in the vault and indexed **in memory at unlock** (a user has tens or hundreds, not thousands). Scoring merges them with the Library results. So nothing about a user's interests sits in plaintext at rest.
- **Lookup** uses the SQL in `sourcetool/build.py` (`_LOOKUP`): term weights plus a category match from the taxonomy, plus a prior. The prior covers tier, validation, authority, votes, terms and usage.
- **The Library page** (`/ni/library`, R9):
Expand All @@ -135,7 +135,7 @@ Flow: the queue feeds `sourcetool ingest` on the operator's side. It validates,
|---|---|---|
| A | Taxonomy v1, schema, `sourcetool` check, validate, harvest, build and lookup; curated US core; 7 harvesters; DuckDB build | **done** |
| B | Repo public and protected; CI (schema check plus validation of changed records); scheduled re-validation | this change |
| C | App: signed pack install, DuckDB tables, lookup in the NI finder, Yes recorded locally, the Library page and form | next (SmartBrain_3000 PR) |
| C | App: pinned pack install, DuckDB tables, lookup in the NI finder, Yes recorded locally, the Library page and form | next (SmartBrain_3000 PR) |
| D | The Library API on the VPS, `sourcetool ingest`, vote aggregation, and the Yes vote plus suggestions from the app | service and ingest built; VPS deploy and the app's calls next |
| E | Model-drafted classification (R3) to fix keyword misplacements; more harvesters (state 511 and GTFS registries, NWS stations, NOAA stations as resolvers) | rolling |

Expand Down
2 changes: 1 addition & 1 deletion docs/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Each source is one JSON object per line in `sources/**/*.jsonl`. Users' suggesti
| `votes` | `{yes, no}`, aggregated from users' Yes/No answers through the Library API. |
| `signals` | Harvest evidence: monthly views, last data update, column names. |
| `origin` | Who or what created the record, and when. |
| `answers` | Curated sources: which paths of the response answer which questions, so the app builds cards without guessing paths. Authored in `answers/<id>.json` (`{source_id, answers, sample_url, checked}`) and merged into the record by `sourcetool build`. A source has 1 to 12 answers, each `{name, label, words, primary?, kind: value\|list\|columns, ...}`; a path segment may be a whole `{param}` of the record, a key the plain names can't spell is quoted (`description["#cdata-section"]`), and a list may `filter` its rows by one or by a short fixed value the response uses (`"Final"`). A `time` whose values carry no zone is shown as the source's local clock; `"utc": true` says those zoneless values are UTC. Spec v1.2 adds what an answer delivers (below). The closed keys, types and limits are in `sourcetool/answers.py` (`answer_problems`), and every path must resolve in a real sample (`check_sample`, run live by `sourcetool answers-check`). |
| `answers` | Curated sources: which paths of the response answer which questions, so the app builds cards without guessing paths. Authored in `answers/<id>.json` (`{source_id, answers, sample_url, checked}`) and merged into the record by `sourcetool build`. A source has 1 to 12 answers, each `{name, label, words, primary?, kind: value\|list\|columns, ...}`; a path segment may be a whole `{param}` of the record, a key the plain names can't spell is quoted (`description["#cdata-section"]`), and a list may `filter` its rows by one or by a short fixed value the response uses (`"Final"`). A `time` whose values carry no zone is shown as the source's local clock; `"utc": true` says those zoneless values are UTC. Spec v1.2 adds what an answer delivers (below). The closed keys, types and limits are in `sourcetool/answers.py` (`answer_problems`), and every path must resolve in a real sample (`check_sample`, run live by `sourcetool answers-check`): the first row of a list in full, and in every later row and every column element a present cell must hold its declared type while a cell the row lacks is allowed (the card shows "—" for it). |

### Answers spec v1.2: what an answer delivers

Expand Down
66 changes: 64 additions & 2 deletions sourcetool/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,41 @@ def _axis_check(w: str, cells: list) -> list[str]:
return [] if bad is _MISSING else [f"{w}: axis cell {_show(bad)} is not an ISO date/time"]


_MAX_CHECKED_ROWS = 500 # the engine's series cap: every row a card could show is typed (spec v1.3, 2026-10-05)
# a cell a row lacks: the app's sparse-row rule (ni_flow._MISSING) shows "—" for these, never a wrong value
_SPARSE_TEXT = frozenset({"", "mm", "n/a", "na", "-", "--", "—", "null", "none", "missing"})


def _sparse(v) -> bool:
return v is _MISSING or v is None or (isinstance(v, str) and v.strip().lower() in _SPARSE_TEXT)


def _check_rows(w: str, items: list, row: list, ex: dict) -> list[str]:
"""Every row after the first (spec v1.3): a cell that is PRESENT must hold its declared type; a cell
the row lacks is allowed (the card shows "—" for it). The caller checks the first row in full. One
message per field, naming the first offending row; bounded by ``_MAX_CHECKED_ROWS``."""
errs: list[str] = []
for j, f in enumerate(row):
for i, it in enumerate(items[1:_MAX_CHECKED_ROWS], start=1): # rows 1..499: the first 500 rows with row 0
v = resolve(it, f["path"], ex)
if _sparse(v):
continue
if not type_ok(v, f["type"]):
errs.append(f"{w} row[{j}] at item {i}: {f['path']} is {_show(v)}, not {f['type']}")
break
return errs


def _check_column(w: str, col: list, f: dict) -> list[str]:
"""Every element of a column (spec v1.3): a present value holds the declared type; nulls are allowed."""
for i, v in enumerate(col[:_MAX_CHECKED_ROWS]):
if _sparse(v):
continue
if not type_ok(v, f["type"]):
return [f"{w} at index {i}: {f['path']} is {_show(v)}, not {f['type']}"]
return []


def check_sample(answers: list, sample, examples: dict | None = None) -> list[str]:
"""Every path resolves in the sample with the type its answer promises. `examples` maps each of the
record's params to its `example` (what a `{param}` segment or a filter stands for in the sample).
Expand Down Expand Up @@ -520,6 +555,7 @@ def check_sample(answers: list, sample, examples: dict | None = None) -> list[st
else:
for j, f in enumerate(a["row"]):
errs += _check_field(f"{w} row[{j}]", items[0], sample, f, ex)
errs += _check_rows(w, items, a["row"], ex)
if "axis" in a:
errs += _axis_check(w, [resolve(it, a["axis"]["cell"], ex) for it in items])
else:
Expand All @@ -530,6 +566,7 @@ def check_sample(answers: list, sample, examples: dict | None = None) -> list[st
errs.append(f"{w} columns[{j}]: {f['path']} is {_show(col)}, not a list")
continue
lengths.add(len(col))
errs += _check_column(f"{w} columns[{j}]", col, f)
if "unit_path" in f and not isinstance(resolve(sample, f["unit_path"], ex), str):
errs.append(f"{w} columns[{j}]: unit_path {f['unit_path']} is not text")
if len(lengths) > 1:
Expand Down Expand Up @@ -674,14 +711,39 @@ def lint_report(recs: list[dict], tax: dict | None = None) -> list[str]:
return out + keyword_lints(recs, tax)


def exempt_key(lint: str) -> str:
"""The key a kind lint is exempted under in lint_exempt.json: ``"<id>: kind <k>"`` — the lint message up to
its " has no answer" tail. One place for the rule, used by the gate and the test suite alike."""
assert isinstance(lint, str), "lint message required"
return lint.split(" has no")[0]


KEYWORD_LINT_BASELINE = ROOT / "tests" / "fixtures" / "keyword_lint_baseline.json"
LINT_EXEMPT = ROOT / "tests" / "fixtures" / "records" / "lint_exempt.json"


def cmd_lint() -> int:
"""`answers-check --lint` (offline). Gating since Phase 0 (2026-10-05): kind lints (minus the reasoned
exemptions in lint_exempt.json, the same list the test suite applies) and utc lints fail; a keyword lint
fails only when it is NEW against the committed baseline — a ratchet, so the backlog never grows and
shrinks as sources are filed under their keywords."""
recs = _records()
loaded, errs = load_answers({k: params_of(r) for k, r in recs.items()})
merged = [{**r, "answers": loaded[k]} if k in loaded else r for k, r in recs.items() if not r.get("replaced_by")]
problems = [f"INVALID {e}" for e in errs] + [f"LINT {p}" for p in lint_report(merged)]
exempt = json.loads(LINT_EXEMPT.read_text()) if LINT_EXEMPT.is_file() else {}
hard = [p for r in merged for p in answer_lints(r) if exempt_key(p) not in exempt]
hard += utc_consistency_lints(merged)
keyword = keyword_lints(merged)
baseline = set(json.loads(KEYWORD_LINT_BASELINE.read_text())) if KEYWORD_LINT_BASELINE.is_file() else set()
new_kw = [p for p in keyword if p not in baseline]
gone = sorted(baseline - set(keyword))
problems = [f"INVALID {e}" for e in errs] + [f"LINT {p}" for p in hard] + [f"LINT NEW {p}" for p in new_kw]
for p in problems:
print(p)
print(f"{len(merged)} records, {len(loaded)} with answers: {len(problems)} lint problems")
if gone:
print(f"{len(gone)} baseline keyword lints no longer fire; drop them from {KEYWORD_LINT_BASELINE.name}")
print(f"{len(merged)} records, {len(loaded)} with answers: {len(problems)} lint problems "
f"({len(keyword) - len(new_kw)} keyword lints held by the baseline)")
return 1 if problems else 0


Expand Down
27 changes: 22 additions & 5 deletions sourcetool/evallookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,39 @@ def _run_classify(name: str) -> int:
return 1 if low else 0


def _servable(ok_ids: list, recs: dict) -> bool:
"""At least one accepted source exists and can be offered (not retired, failed or refused). A row whose
every accepted source is unofferable (the refused ESPN endpoints, an id that was never a record) cannot
pass or fail a lookup; it is reported as unservable (Phase 0, 2026-10-05)."""
for i in ok_ids:
r = recs.get(i)
if r and not r.get("replaced_by") and (r.get("validation") or {}).get("status") not in ("failed", "refused"):
return True
return False


def run(name: str = "lookup_asks") -> int:
if name.startswith("classify_"):
return _run_classify(name)
from .answers import _records
from .build import lookup
spec = json.loads((ROOT / "tests" / f"{name}.json").read_text())
recs = _records()
cases = [c for c in spec["cases"] if _servable(c["ok"], recs)]
unservable = [c for c in spec["cases"] if not _servable(c["ok"], recs)]
top1 = top3 = 0
fails = []
for c in spec["cases"]:
for c in cases:
ids = [r["id"] for r in lookup(c["ask"], limit=3)]
ok1 = bool(ids) and ids[0] in c["ok"]
ok3 = any(i in c["ok"] for i in ids)
top1 += ok1
top3 += ok3
if not ok1:
fails.append(f" {'top3' if ok3 else 'MISS'} {c['ask']!r}: got {ids}")
n = len(spec["cases"])
print(f"{name}: top-1 {top1}/{n} ({100 * top1 // n}%), top-3 {top3}/{n} ({100 * top3 // n}%)")
print("\n".join(fails))
return 0 if top1 == n else 1
n = len(cases)
pct = (lambda k: 100 * k // n) if n else (lambda k: 0)
print(f"{name}: top-1 {top1}/{n} ({pct(top1)}%), top-3 {top3}/{n} ({pct(top3)}%); "
f"{len(unservable)} unservable (every accepted source refused, failed or missing)")
print("\n".join(fails + [f" UNSERVABLE {c['ask']!r}: {c['ok']}" for c in unservable]))
return 0 if n and top1 == n else 1 # an all-unservable file is a broken set, never a pass
Loading
Loading