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
    
  1. Link. Each chunk’s mentions are linked to FoodOn IDs by the dense linker. FoodOn is a ~39k-term ontology with a real is-a hierarchy: olive oil vegetable oil food product.

  2. 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.

  3. 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:

  1. NER entity_type"nutrient"→nutrients, "medical condition"→health, "allergen"→allergies, … (ENTITY_TYPE_TO_FACET). The cleanest signal when the NER populates it.

  2. FOODON id → foods — any FOODON: link is a food regardless of entity_type.

  3. OBO prefix fallback (PREFIX_TO_FACET) — when the NER left entity_type='other' (the prototype case), route the non-FOODON OBO link by its ontology prefix: CHEBI/CDNO/ONSnutrients, UBERON/MONDO/PATOhealth, ENVO/GAZsustainability.

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

fruit produce

633

633

0

genuine topic — named outright

vegetable

542

542

0

genuine topic

cheese

239

239

0

genuine topic

meat (raw)

478

407

71

mostly named, some lifted

mammalian milk product

649

1

648

category — evidence is all descendants

vertebrate food product

2016

0

2016

pure umbrella — nobody writes the phrase

Foods

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.