Layer A — Backbone¶
Layer A is the navigation skeleton: a curated, multi-facet menu of shelves projected from the FoodOn ontology, populated only with the parts of the ontology that the corpus actually talks about.
From mentions to shelves¶
flowchart LR
Ch[Chunk] -->|NER| Me[Mentions]
Me -->|dense linker| Ids[FoodOn IDs]
Ids -->|walk ancestors| Sup[Support table]
Sup -->|project + prune| Sh[Shelves]
Ch -.attach.-> Sh
Link. Each chunk’s mentions are linked to FoodOn IDs by the dense linker. FoodOn is a ~39k-term ontology with a real
is-ahierarchy:olive oil → vegetable oil → … → food product.Collect support. For every linked ID, walk its FoodOn ancestors and tally how many chunks mention each class directly vs. via a descendant (lifted support). This is the evidence each candidate shelf carries.
Project & prune. Build a navigable tree from the supported classes, keeping only those with real corpus evidence. ~39k FoodOn terms collapse to a few hundred shelves per facet — the ones this corpus justifies.
A chunk can attach to multiple shelves (a passage about “salmon poached in olive
oil” attaches to both salmon and olive oil). This multi-label attachment matters
later — it’s why Layer B themes must be tied to an origin shelf rather than the
union of their chunks’ shelves (see Layer B — Themes).
Facets¶
Shelves are grouped into six facets, each a separate projection:
foods · health · nutrients · dietary_patterns · allergies · sustainability
A chunk’s entity links route to the relevant facet(s) via
layer_a.facet.route_link_to_facet, in this precedence:
NER
entity_type—"nutrient"→nutrients,"medical condition"→health,"allergen"→allergies, … (ENTITY_TYPE_TO_FACET). The cleanest signal when the NER populates it.FOODON id →
foods— anyFOODON:link is a food regardless of entity_type.OBO prefix fallback (
PREFIX_TO_FACET) — when the NER leftentity_type='other'(the prototype case), route the non-FOODON OBO link by its ontology prefix:CHEBI/CDNO/ONS→nutrients,UBERON/MONDO/PATO→health,ENVO/GAZ→sustainability.
Important
The OBO-prefix fallback is what lets the non-food facets populate on a corpus whose
NER tagged every mention entity_type='other'. It requires ontology.prefix_filter to
admit those OBO prefixes (they’re embedded in foodon.owl) — a bare ["FOODON:"]
filter projects foods only. On the reference corpus, enabling it took
nutrients/health/sustainability from a lone stub root to ~120/64/6 shelves.
Trade-off: the prototype linker mis-assigns across OBOs, so prefix routing inherits some
noise. The cleaner long-term fix is re-annotation with a NER that populates
entity_type (see the methods brief). allergies / dietary_patterns stay at a stub
root until the corpus actually links allergen / dietary-pattern entities.
The projection method¶
Layer A’s construction method is selected by config.layer_a.projection. The
production default is "backbone" — the 1a+ backbone projection (the name is from
the method bake-off: method “1a” plus refinements):
Start from the facet root’s supported children (the backbone).
Expand down the real FoodOn tiers, but collapse single-child filing tiers — organizational classes with one child that add depth without aiding navigation.
Place every node under a single parent, cap fan-out (
backbone_max_children; overflow children fold into the nearest kept ancestor, never dropped silently), and prune empty dead-ends.
The result is faithful: every shelf is a real FoodOn class, the tree’s edges are
real is-a relations, and original labels/IDs are untouched.
flowchart LR
subgraph raw[Raw FoodOn subtree]
r0[food product] --> r1[…186 siblings…]
r0 --> r2[milk or milk based food product]
r2 --> r3[dairy food product]
r3 --> r4[mammalian milk product]
end
subgraph bb[Backbone projection]
b0[Foods] --> b1[milk or milk based food product]
b1 --> b2[mammalian milk product]
end
raw -->|collapse filing tiers,<br/>cap fan-out, prune| bb
Why not just use FoodOn’s tree as-is?
The raw ontology is unbalanced for browsing — a flat ~186-wide foods blob in
places, deep filing chains in others, and an “umbrella” class (food product) that
absorbs generic mentions. The backbone projection re-cuts it into a navigable shape
without leaving FoodOn’s ID space.
The fallback prune cascade¶
projection="prune" selects the earlier top-down method, kept as a non-default
alternative. It applies, in order: blacklist → umbrella rule (drop inflated
organizational classes whose support is almost entirely lifted) → whitelist →
support threshold (min_support) → depth cap → single-child collapse. Both methods
are is-a-faithful; backbone is the validated production choice. A third opt-in
method, bottom-up LLM grouping (bottom_up_grouping.enabled), exists for facets that
benefit from it.
Note
How the method was chosen — the metrics (coverage, findability, depth, reproducibility)
and the bake-off that compared the candidates — is preserved as research provenance
under the repo’s research/ directory.
Aliasing¶
FoodOn labels are often jargon (Citrus sinensis (whole, raw)). After projection, an
optional LLM aliasing pass (config.layer_a.alias_shelves, on by default when an
LLM is configured) gives jargon shelves a friendly display_label. This is purely
additive — it never changes a shelf’s label, foodon_id, or position in the
tree, so the projection stays auditable while the UI stays readable.
The Shelf record¶
Each shelf carries its identity, position, and the evidence behind it:
class Shelf(BaseModel):
shelf_id: ShelfId # e.g. "foodon:FOODON:03309927"
label: str # FoodOn label
display_label: str | None # LLM alias for the UI (additive)
facet: Facet # foods | health | nutrients | ...
depth: int # projection-relative depth (0 = root)
foodon_id: str | None # the ontology class this shelf represents
parent_shelf_id: ShelfId | None
chunk_count: int # total attached chunks (incl. descendants)
support_direct: int # chunks mentioning this exact ID
support_lifted: int # support inherited from descendants
see_also: list[str] # IDs collapsed into this shelf
support_direct vs. support_lifted is the key diagnostic: a shelf with high lifted
but low direct support is mostly an organizational umbrella; one with high direct
support is a genuine topic the corpus discusses by name.
Reading real shelves¶
Actual foods shelves from a build make the diagnostic concrete:
shelf |
chunks |
direct |
lifted |
reading |
|---|---|---|---|---|
|
633 |
633 |
0 |
genuine topic — named outright |
|
542 |
542 |
0 |
genuine topic |
|
239 |
239 |
0 |
genuine topic |
|
478 |
407 |
71 |
mostly named, some lifted |
|
649 |
1 |
648 |
category — evidence is all descendants |
|
2016 |
0 |
2016 |
pure umbrella — nobody writes the phrase |
|
3893 |
0 |
3893 |
the facet root |
The umbrellas (Foods, vertebrate food product) carry the most chunks but ~zero direct
support — they exist to organize, not to be browsed to. The genuine topics are where a
user actually lands.
How chunks attach¶
A chunk’s linked FoodOn ids are resolved to surviving shelves: a direct hit attaches
to that shelf; otherwise the id is lifted up the is-a tree to the nearest kept ancestor.
Because a chunk usually carries several ids — and each lifts independently — one chunk
attaches to multiple shelves (see lifted attachment). This multi-attachment is
recorded as shelf_ids on the chunk (denormalized to Elasticsearch) and is exactly why
Layer B must tie a theme to its origin shelf rather than the union of
its chunks’ shelves.
Building it¶
fs.build_layer_a() # project shelves for every configured facet (+ aliasing)
fs.attach() # attach chunks to shelves (writes shelf_ids denorm)
fs.graph.shelves(facet="foods") # browse the result
fs.viz.layer_a_tree("foods").render("tree", output="tree.html") # see it
The Guides section covers the end-to-end build pipeline and the fs.graph read API.