FoodScholar — the facade

FoodScholar is the single object you work through. It owns the configured stores, the ontology, the LLM client, and the embedder, and exposes every pipeline phase as a method. Construct it with from_config for real work, or in_memory for a zero-setup instance.

The sub-surfaces hang off it as attributes:

.. py:class:: FoodScholar(config, *, chunk_store, graph_store, embedder=None, llm=None, entity_store=None, card_store=None)

module:

foodscholar.facade

Bases: :py:class:object

User-facing facade for the library.

.. py:property:: FoodScholar.embedder

module:

foodscholar.facade

type:

~foodscholar.storage.protocols.Embedder

Lazily-built chunk embedder.

For memory backends or when the [annotate] extra is missing, this is _MockEmbedder(). For production it is HFEmbedder("BAAI/bge-base-en-v1.5") (BRIEF §2 — single embedder across source types). First access pays the model-load cost; subsequent accesses are free.

.. py:method:: FoodScholar.in_memory(*, config=None, embedder=None, llm=None)

module:

foodscholar.facade

classmethod:
Zero-config facade backed by `InMemoryChunkStore` + `InMemoryGraphStore`.

Intended for notebooks, tests, and quick experiments. Pass a `config`
(dict, YAML path, or `FoodScholarConfig`) to override defaults.

.. py:method:: FoodScholar.from_config(config, *, embedder=None, llm=None) :module: foodscholar.facade :classmethod:

Construct from a YAML path, a Python dict, or a validated config.

Builds whichever stores the config declares. `memory` works today;
`elastic` and `neo4j` adapters lift the same constructor.

.. py:method:: FoodScholar.info() :module: foodscholar.facade

.. py:method:: FoodScholar.load_chunks(path) :module: foodscholar.facade

Read chunks from a parquet/jsonl/csv path and upsert into the chunk store.

.. py:method:: FoodScholar.upsert_chunks(chunks) :module: foodscholar.facade

Upsert an explicit list of chunks (useful in tests and notebooks).

.. py:method:: FoodScholar.load_and_annotate(path, *, snapshot_path=None, ignore_source_types=None) :module: foodscholar.facade

Single-pass: load chunks → run GLiNER+HNSW annotate → optional snapshot.

This is the release-ready entry point that mirrors the validated
prototype's `main()` — one call per corpus file. If a parquet snapshot
is configured (via `snapshot_path` here or `cfg.corpus.annotated_snapshot_path`)
and already exists with non-zero size, the call short-circuits and
returns None — matching the prototype's skip-if-output-exists idempotency.

``ignore_source_types`` (defaults to ``cfg.corpus.ignore_source_types``)
drops chunks whose ``source_type`` is in the set before annotation —
useful to skip e.g. all ``abstract`` chunks when ingesting a
guideline-only knowledge base.

.. py:method:: FoodScholar.ingest(corpus_dir, *, nel_dir=None, snapshot_path=None, ignore_source_types=None) :module: foodscholar.facade

Ingest corpus + annotations into `fs.chunk_store`. Two modes:

- ``nel_dir`` **supplied** → annotations come from pre-computed
  `(chunk_id, chunk_entities_ner, chunk_uri_nel)` CSVs (the prototype's
  output shape). Chunks are loaded from ``corpus_dir``, annotations
  attached by `chunk_id`, and everything upserted to `fs.chunk_store`.
  **No GLiNER, no HNSW, no chunk embedding** — fast, deterministic,
  works without the `[annotate]` extra installed. Call `fs.embed()`
  afterwards to fill in chunk vectors for kNN search.

- ``nel_dir`` **omitted** → falls back to `load_and_annotate(corpus_dir)`
  which runs GLiNER + HNSW from scratch (this path does embed, since
  the runner has the BGE-base embedder already loaded).

``snapshot_path`` (or `cfg.corpus.annotated_snapshot_path`) writes a
parquet snapshot of the annotated chunks after ingest. If the snapshot
already exists and is non-empty the whole call short-circuits — the
same idempotency guarantee as `load_and_annotate`.

``ignore_source_types`` (defaults to ``cfg.corpus.ignore_source_types``)
drops chunks whose ``source_type`` is in the set before upsert. Their
NEL rows are skipped too — nothing about them reaches the chunk store
or shows up in ``fs.entities`` later. Pass e.g. ``{"abstract"}`` to
ingest only textbook + guide chunks.

.. py:property:: FoodScholar.ontology :module: foodscholar.facade :type: FoodOnAPI

.. py:method:: FoodScholar.load_ontology(*, refresh=False) :module: foodscholar.facade

.. py:method:: FoodScholar.attach_ontology(api) :module: foodscholar.facade

.. py:property:: FoodScholar.ner :module: foodscholar.facade :type: ~foodscholar.storage.protocols.NER

Lazily-built NER. `cfg.annotate.ner = 'gliner'` (the only choice in v0.1).

Override with `fs.attach_ner(...)` before first access to install a custom NER.

.. py:property:: FoodScholar.linker :module: foodscholar.facade :type: ~foodscholar.storage.protocols.Linker

`HNSWLinker` over `HNSWNELIndex`.

First access builds the FoodOn term index (or loads it from the cache
path) — that's the expensive call; subsequent accesses are free.

:type: Lazily-built linker. Default

.. py:method:: FoodScholar.attach_ner(ner) :module: foodscholar.facade

.. py:method:: FoodScholar.attach_linker(linker) :module: foodscholar.facade

.. py:method:: FoodScholar.embed(*, only_missing=True, batch_size=64) :module: foodscholar.facade

Fill in chunk-text embeddings for chunks already in `fs.chunk_store`.

Walks the store, encodes each chunk with the configured embedder
(BGE-base for production — BRIEF §2/§7), and writes back only the
`embedding` + `embedding_model` fields via
`chunk_store.update_embeddings_bulk`. Mentions, links, and other
annotations are untouched.

- `only_missing=True` (default): skip chunks whose `embedding_model`
  is already a real model id (anything that isn't the deterministic
  `mock-embedder-v0`). Re-runs are cheap.
- `only_missing=False`: re-encode every chunk regardless. Useful
  after swapping the configured embedder.

Builds the production embedder lazily on first call — that is the
~440 MB BGE-base load, paid once per process.

.. py:method:: FoodScholar.build_entities(*, cap_chunk_sample=None) :module: foodscholar.facade

Derive first-class `Entity` records from the chunks already in the
store and write them to (a) `fs.entity_store`, (b) `fs.graph_store`
as `(:Entity)` nodes with `(:Chunk)-[:MENTIONS]->(:Entity)` edges.

Walks `fs.chunk_store.iter_chunks(...)`, dedupes `EntityLink`s by
`ontology_id`, aggregates `(mention_count, chunk_count, chunk_ids,
facet_hint, last_seen)`, and enriches with `(label, synonyms,
ancestor_ids)` from `fs.ontology` when an ontology is configured AND
the entity id is a FOODON id (other OBO prefixes ship with the
most-frequent surface form as the label and no ancestors).

Idempotent — re-running over an unchanged corpus produces the same
Entity records; re-running after `fs.ingest` of new chunks updates
counts and the chunk_ids sample.

.. py:method:: FoodScholar.annotate() :module: foodscholar.facade

Run NER + linking + embedding over every chunk in `chunk_store`.

.. py:method:: FoodScholar.init() :module: foodscholar.facade

Provision the backing stores declared by the config.

Calls `chunk_store.init()`, `entity_store.init()`, `graph_store.init()`,
and `card_store.init()` — all are in the storage protocols and are
no-ops for the in-memory backends, so this works uniformly regardless
of where the stores live.

.. py:method:: FoodScholar.build_layer_a() :module: foodscholar.facade

.. py:method:: FoodScholar.attach() :module: foodscholar.facade

.. py:method:: FoodScholar.semantic_consolidate(*, facet=’foods’, dry_run=True) :module: foodscholar.facade

Embed shelves, find near-duplicate pairs, and merge via an LLM judge.

Runs *after* `fs.attach()` so the judge can ground each decision on
real sample chunks (it reads `chunk.shelf_ids`, written by attach).

- `dry_run=True` (default): read-only. Returns the
  `ConsolidationArtifact` — candidates, decisions, and what the
  pre-LLM filters dropped — for inspection. Nothing is persisted.
- `dry_run=False`: applies confirmed merges (above
  `auto_merge_confidence`), re-persists the shelf set, and re-runs
  `fs.attach()` so the merged-away shelves' chunks re-home onto the
  surviving canonical shelf.

Set `layer_a.semantic_consolidation.judge_enabled=False` for a
zero-cost candidate preview (no LLM calls).

.. py:method:: FoodScholar.audit() :module: foodscholar.facade

Run cross-store invariant checks and return a structured report.

Read-only — never writes. Returns an `AuditReport` with five sections:
inventory, coverage, cross-store consistency, attach integrity, and
structural sanity. `report.passed` is True iff zero critical checks
failed; `report.critical_failures` lists the broken invariants.
Print `report` directly for a human-readable summary.

.. py:method:: FoodScholar.quality_report(*, facet=’foods’, top_n=20, sample_size=20, canonical_terms=None, seed=0) :module: foodscholar.facade

Produce a domain-expert quality report for one facet of Layer A.

Pairs with `fs.audit()` but answers a different question: not "is the
graph correctly built" (invariants) but "is the graph good"
(semantic / coverage). Read-only; the output is a Pydantic
`QualityReport` whose `__str__` is Markdown for direct notebook
viewing.

Sections:

1. Top shelves at a glance — table sorted by chunk_count with
   direct/lifted split + 3 example chunk snippets per shelf.
2. Hierarchy walkthrough — parent chains + sample descendants for
   the top 5 shelves, so the expert can sanity-check navigation.
3. Suspicious shelves — conservative heuristic flags (EFSA-style
   code prefixes, "datum" labels, collapse-misses, zero-chunk
   survivors).
4. Canonical vocabulary check — checklist of foods/nutrients/
   conditions an expert would expect, with status per term.
   Override the default list via `canonical_terms=`.
5. Random chunk sample — `sample_size` randomly-chosen attached
   chunks with their text + attached shelf labels, formatted for
   hand audit per BRIEF §17. `seed` for reproducibility.

.. py:method:: FoodScholar.build_layer_b(*, facet=’foods’, dry_run=False) :module: foodscholar.facade

Build Layer B themes for `facet`.

For each shelf with `≥ cfg.layer_b.min_chunks_per_shelf` attached
chunks whose embedded-fraction clears `cfg.layer_b.min_embedded_fraction`,
runs the dual-pass pipeline (similarity + relatedness), merges
candidates greedily, labels themes (c-TF-IDF + LLM polish when
`cfg.layer_b.labeling.strategy == "llm"`), picks a per-pass-aware
primary chunk, and persists:
  - `(:Theme)` nodes + `(:Shelf)-[:IN_SHELF...]->(:Theme)` edges
  - `(:Chunk)-[:THEME_OF {primary, weight}]->(:Theme)` edges
  - ES `theme_ids` denorm via `bulk_set_theme_ids` (preserves
    `shelf_ids`)

Skips the synthetic facet root (`facet:foods` etc.) — that's the
iteration-8 unclassified bucket.

`dry_run=True` runs the full pipeline but skips all writes. Useful
for `n_themes` estimates and audit-decision inspection without
modifying the stores.

Returns a `LayerBArtifact` summarizing the run (themed/skipped
shelf counts, total themes, per-pass distribution, leiden seed,
timestamps).

See `layer_b_construction_brief.md` for the full architecture.

.. py:method:: FoodScholar.build_quality_report(*, facet=’foods’) :module: foodscholar.facade

Read-only WARN-level quality report for Layer B of `facet`.

Pairs with `fs.audit()` (CRITICAL invariants) but answers "is the build
*good* / well-tuned" rather than "is it correct". Reads shelves, themes,
and attachments; mutates nothing. Returns a `LayerBQualityReport` whose
`__str__` is Markdown for notebook viewing — structural stats (shelves,
depth, fanout, support ratios), theme stats (coverage, source mix,
duplicate/tiny/leakage counts), and a list of `LayerBWarning`s.

Warning thresholds come from `cfg.layer_b.audit`. See
`layer_b/quality.py` for the full metric + warning list.

.. py:method:: FoodScholar.sweep_layer_b(*, facet=’foods’, grid=None) :module: foodscholar.facade

Non-mutating tuning sweep over a grid of Layer B configs.

Runs each config combination as a `dry_run` build with cheap keyword
labels and `per_shelf` Pass 1, scores the resulting quality metrics, and
returns a ranked `SweepResult` (best first). Nothing is persisted — apply
the winning config (`result.best`) yourself and rebuild.

`grid` maps dotted `layer_b` config paths (e.g.
`"leiden.min_community_size"`) to candidate values; defaults to the full
160-config Cartesian product (`sweep.DEFAULT_GRID`). Scoring weights are
fixed and documented in `layer_b/sweep.py`.

.. py:method:: FoodScholar.build_layer_c(*, facet=’foods’, dry_run=False) :module: foodscholar.facade

Build Layer C — one summary Card per Layer B theme of `facet`.

Each theme's member chunks are compressed by a cheap extractive method
(Stage 1, map-reduce when large), then refined by the LLM into a Card
(Stage 2). `dry_run=True` runs both stages but skips persistence.
Returns a `LayerCReport`.

.. py:method:: FoodScholar.benchmark_layer_c(*, facet=’foods’, themes=5, out=None) :module: foodscholar.facade

Read-only benchmark of all extractive methods over the largest
`themes` themes of `facet`. Writes per-method JSON metrics; returns the
results keyed by theme id. No LLM, no persistence.

.. py:method:: FoodScholar.export_graphml(output, *, facet=’foods’) :module: foodscholar.facade

Export the Layer A/B/C graph (shelves + themes + cards) to GraphML.

Typed nodes (`shelf` / `theme` / `card`) with their attributes and edges
(`parent_of` / `has_theme` / `has_card`), readable by Gephi, Cytoscape,
yEd, etc. `facet=None` exports every facet. Returns the output `Path`.

.. py:method:: FoodScholar.search_cards(text, *, k=10) :module: foodscholar.facade

Vector-search Layer C cards by `text`. Embeds the query with the
chunk embedder, runs kNN over the card store, and returns the matching
`Card`s nearest-first. (A thin retrieval helper; full `query()` with
answer synthesis is still deferred.)

.. py:method:: FoodScholar.build() :module: foodscholar.facade

.. py:method:: FoodScholar.query(text) :module: foodscholar.facade