DR McCullochHOMESHOPABOUT

Vernacular · plants-for-health-1 · the offline bundle

Vernacular

It remembers every connection and almost none of the names

Plants for Health is Kew's medicinal plant portal, and plants-for-health-1 is the standalone prototype of it I built to run with no server and no network: a progressive web app over an RDF graph of medicinal taxa, cached onto the device, costing nothing to host. My pipeline reported 2,529,435 triples, and what actually reached the shipped chunks is 1,099,404 distinct lines across five predicates, with no rdf:type, no rdfs:label and no dwc:scientificName among them, so a browser holding the entire offline bundle can tell you that Artemisia annua L. has six parts and contains 232 compounds while having no way at all to say what it is called.

Triples generated
2,529,435
Lines written to chunks
1,411,930
Distinct on disk
1,099,404
Predicates
5
Labels
0
Chunks listed / non-empty
31 / 30
Walk it ↓

i Five predicates and no others

I counted these by streaming all 30 .ntriples files line by line and hashing each line, so the figure is distinct triples rather than the manifest's own sum, which is 1,411,930 because a triple that lands in both an alphabetical chunk and a relationship chunk gets counted twice. Set against what the pipeline says it made, that leaves 1,430,031 generated triples in no chunk at all. Free text is 61.8% of what shipped, because the generator copied the relationshipRemarks column of the Darwin Core export into a dcterms:description literal on every relationship row it touched, and those literals came through the chunker while most of the typed triples did not.

  • dcterms:description<http://purl.org/dc/terms/description>678,974
  • pfh:hasVernacularName<http://plantsforhealth.kew.org/ontology#hasVernacularName>266,555
  • obo:RO_0001019<http://purl.obolibrary.org/obo/RO_0001019>71,138
  • obo:BFO_0000051<http://purl.obolibrary.org/obo/BFO_0000051>48,581
  • rdfs:subClassOf<http://www.w3.org/2000/01/rdf-schema#subClassOf>34,156

And these, which the pipeline made and the chunker dropped

  • rdf:typetwo per taxon, dwc:Taxon and pfh:MedicinalTaxon, from process_taxon_data0
  • rdfs:labelone per named taxon and one per vernacular name, the only join between a number and a word0
  • dwc:scientificNameone per taxon row that carries a scientificName0
  • obo:RO_0000087has_role, the Part-Use and Chemical-Use edges README.md:104–105 declares0

What the free text says, in five fixed shapes

Every one of the 678,974 literals is the subject, a verb and the object written out as a sentence, and they come in five fixed shapes. Two of them restate an edge that is already sitting there in typed form. The other three are the only record of their relationship anywhere in the bundle, which is how a knowledge graph ends up keeping its knowledge in prose.

  • 459,584"Camellia sinensis (L.) Kuntze leaf contains (-)-EPIAFZELECHIN-3-O-GALLATE"the only place a part-level chemistry claim exists at all
  • 86,538"Artemisia annua L. hasVernacularName qing hao"the only place any vernacular name exists
  • 71,138"Artemisia annua L. contains CAMPHENE-HYDRATE"a restatement in English of the RO_0001019 edge beside it
  • 34,156"Anacyclus radiatus Loisel. subClassOf Anacyclus"a restatement in English of the rdfs:subClassOf edge beside it
  • 27,558"Aloe vera (L.) Burm.f. has part leaf exudate"the only place a part is ever named on the taxon itself

Those has part sentences are the only place a part gets a name on the taxon, and they do not line up with the edges they sit beside. 8,917 taxa carry BFO_0000051 edges, 5,667 of them name as many parts as they have edges and 3,250 name fewer, so even counting the two against each other does not tell you which identifier is the leaf.

The shape underneath is lopsided in a way I did not design. Of the 34,157 taxon subjects on disk, 21,106 carry no part, compound or vernacular edge at all and hold two triples each: an rdfs:subClassOf pointing at their genus and one description literal saying the same thing again in English. 2,888 carry taxon-level RO_0001019 edges, 71,138 of them, reaching 21,073 distinct compounds, and those are the ones with enough hanging off them to be worth walking.

ii The walk

Start where I started, at Artemisia annua L., and move. Each hop prints the line it walked in N-Triples exactly as it reads in the chunk file, and every name is tagged with where it came from, because there are only two places a name survives here: inside the URI, where percent-decoding gets it back, or inside a free-text description literal, where I have to scrape it out. Follow contains to a compound and the walk lists every taxon in the bundle that contains it, which is the only way to ask that question offline, since nothing in the bundle has a compound URI as a subject and the answer has to be assembled by reading all 71,138 edges into memory first.

a plain prefix filter over 34,157 taxon subjects, 6,201 genera and 21,073 compounds held in memory. The FST autosuggest index sitting beside the bundle holds 33 terms.
Show names as
raw is what the bundle actually holds

Loading the offline bundle…

The trail

every line below is on disk, byte for byte

    Nothing here is paraphrased. The subject URIs, the object URIs and the literals are reconstructed from the percent-encoded strings the extractor lifted out of the files, and it checked all 678,974 description literals against that reconstruction with 0 mismatches, so every line in the trail is a line you would find with grep.

    iii Four things I got wrong

    All four sit in one file of Python I committed myself and never touched again: one commit, the eleventh of the 32 in that repository. Each is a couple of lines that read fine and threw away a whole category of triple, and none of them raised an error, because a filter that matches nothing is indistinguishable from a filter that has nothing to match.

    i

    The essentials chunk looks for a syntax N-Triples never writes

    utils/data-processing/prepare_taxon_rdf_triple_store.py:393–397

    for line in lines:
        if any(pattern in line for pattern in ['rdf:type', 'rdfs:label', 'dwc:scientificName']):
            essentials.append(line)

    All three of those are CURIEs, and the serialiser writes N-Triples, which spells every predicate out in full as <http://www.w3.org/2000/01/rdf-schema#label>, so the substring 'rdfs:label' occurs in exactly none of the 2,529,435 generated lines. The chunk I wrote to hold the most important data is on disk as a 45-byte gzip stream holding zero triples, and its manifest entry says triple_count 0.

    essentials.ntriples.gz
    45 bytes
    triples in it
    0
    ii

    The alphabetical filter and the label generator disagree about what a taxon is called

    prepare_taxon_rdf_triple_store.py:408 and 182–191, with data/darwin-core/output-clean/taxon.tab

    if 'urn:lsid:plantsforhealth.kew.org:taxon:' in line:
        …
    # but the label was minted against taxonID, which is an IPNI LSID:
    taxon_uri = self.create_safe_uri(taxon_id)      # urn:lsid:ipni.org:names:1000018-1
    self.graph.add((taxon_uri, RDFS.label, Literal(row['scientificName'])))
    self.graph.add((taxon_uri, DWC.scientificName, Literal(row['scientificName'])))

    The taxon.tab in that tree has 54,207 rows, which is what the manifest reports processed, and 38,661 of them carry a scientificName, so the generator minted 38,661 rdfs:label triples and 38,661 dwc:scientificName triples. Every one of them hangs off an IPNI LSID. The alphabetical chunker keeps a line only if it contains the string urn:lsid:plantsforhealth.kew.org:taxon:, and the relationship chunker only dispatches on relationship words, so all of them fell through both filters. Not one label or scientific name is on disk, and the two rdf:type assertions per taxon went the same way.

    rdfs:label in the chunks
    0
    dwc:scientificName in the chunks
    0
    iii

    Two relationship chunks were never written, and a third holds the wrong thing

    prepare_taxon_rdf_triple_store.py:426–439

    if 'subClassOf' in line:      chunks['taxonomic'].append(line)
    elif 'has_part' in line:      chunks['structural'].append(line)
    elif 'contains' in line:      chunks['chemical'].append(line)
    elif 'has_role' in line:      chunks['functional'].append(line)
    elif 'hasVernacularName' in line: chunks['vernacular'].append(line)

    has_part serialises as BFO_0000051 and has_role as RO_0000087, so relationships-structural and relationships-functional do not exist on disk and RO_0000087 occurs zero times in the whole bundle. The 'contains' branch fares worse than nothing: it matches the English word inside a description literal, so relationships-chemical is 96,200,279 bytes of sentences like "Artemisia annua L. contains CAMPHENE-HYDRATE" and holds not one RO_0001019 edge. The real contains edges survived only because they happen to mention a taxon URI and so landed in an alphabetical chunk.

    relationship chunks the code names
    5
    relationship chunks on disk
    3
    obo:RO_0000087 shipped
    0
    iv

    The vernacular names kept their labels in the graph and lost them on the way out

    prepare_taxon_rdf_triple_store.py:281–295

    vernacular_uri = self.create_safe_uri(vernacular_name_id)
    self.graph.add((vernacular_uri, RDF.type, PFH.VernacularName))
    self.graph.add((vernacular_uri, RDFS.label, Literal(vernacular_name)))
    self.graph.add((vernacular_uri, PFH.vernacularNameText, Literal(vernacular_name)))
    …
    if taxon_id in self.taxon_cache:
        taxon_uri = self.taxon_cache[taxon_id]
        self.graph.add((taxon_uri, PFH.hasVernacularName, vernacular_uri))

    180,054 vernacular rows were processed and every one got an rdfs:label carrying the actual word. The subject of that label is a vernacularName URI, which matches neither filter, so none of those labels ever reached a chunk. What shipped is the pointing half of the pair: 266,555 hasVernacularName edges aimed at bare numbers. 180,017 of those hang off IPNI LSIDs that appear as a subject nowhere else in the bundle and as an object nowhere at all, which leaves them pointing out of the graph from a node you cannot reach. The remaining 86,538 do hang off taxon URIs, and for every one of the 8,562 taxa involved the number of ids is exactly the number of name strings sitting in that taxon's description literals. The two halves are the same size and nothing joins them.

    pfh:hasVernacularName edges
    266,555
    off an orphan IPNI subject
    180,017
    ipni URIs used as an object
    0

    One more, and this one is not mine

    1,412 taxa on disk have more than one named part, and 1,262 of them (89.4%) assert an identical compound set on every single part. Camellia sinensis (L.) Kuntze carries 15 parts with compounds recorded against them, from anther to tissue culture, and every one is said to hold the same 641. Papaver somniferum L. is 17 parts by 247, Curcuma longa L. six by 180. It is a cross product, and it arrives that way from upstream rather than from anything my chunker did: in the Darwin Core export sitting in that tree, 999 taxa have more than one part with compounds recorded against it and 794 of them repeat one identical set across every part. The 150 on disk that do vary are the interesting ones.

    • Malus domestica (Suckow) Borkh. 22 parts, 3 to 470 compounds
    • Apium graveolens L. 12 parts, 3 to 469 compounds
    • Punica granatum L. 18 parts, 3 to 265 compounds
    • Salvia officinalis L. 14 parts, 1 to 255 compounds
    • Citrullus lanatus (Thunb.) Matsum. & Nakai 15 parts, 7 to 217 compounds
    • Thymus vulgaris L. 6 parts, 2 to 187 compounds

    iv What going offline cost

    The autosuggest index the whole search rests on is 28,852,354 bytes and holds 2,859,635 names. The sidecar you need to turn a hit in that index into something you can resolve is 314,045,154 bytes, nearly eleven times as much, so the part that makes the search fast is a tenth of what a phone has to carry before a result means anything. Neither file goes anywhere near the offline bundle: the index sitting beside those chunks holds 33 terms built from three fixture taxa.

    The index files, under rust-autosuggest/public/data

    • fst-indices/pfh_names.fst27.5 MB2,859,635 terms, per commit a4cecf3
    • pfh_name_metadata.json299.5 MB1,059,753 records with IPNI resolution, per commit a4cecf3
    • pfh_names_sorted.txt81.9 MBthe sorted term list the FST was built from, 2,859,635 lines
    • fst-indices/scientific_names.fst876.8 KB38,661 terms, source taxon.tab
    • fst-indices/vernacular_names.fst478.9 KB50,918 terms, source vernacularname.tab
    • fst-indices/genera.fst43.7 KB6,623 terms, source taxon.tab
    • fst-indices/families.fst93 bytes1 term, source taxon.tab

    The FST that sits beside the offline bundle, in public/data/fst-indices at the root of that tree, holds 33 terms across 23 shards by its own manifest. It was built from data/taxa.json, which is the three fixture taxa hard-coded in build_indices.rs create_sample_data(): Artemisia annua, Echinacea purpurea and Matricaria chamomilla. The real indices above never left rust-autosuggest, and portal/public holds nothing but an icons folder, a manifest, the service worker and the script that registers it.

    The targets I set myself

    Autosuggest Latency<1ms✅
    Initial Load<3s✅
    RDF Query (1-degree)<10ms✅
    Vector Search (10k docs)<100ms✅
    Server RAM10MB✅
    Server Cost$0/month✅
    Offline Capable100%✅

    Every row in docs/performance/README.md is marked done. The service worker that was supposed to make the last one true is 67 lines, and its entire install list is four URLs: /, /manifest.json, /icons/icon-192x192.png, /icons/icon-512x512.png, with no RDF chunk and no index among them. RDF reaches the cache only if the page posts a CACHE_RDF_DATA message, and nothing in the portal posts one.

    The storage tiers, as specified and as written

    • HOT (RAM): <10KB - Current working set (active taxon data)a Map in memory, implemented
    • WARM (IndexedDB): <1MB - Recent queries and frequently accessed dataimplemented
    • COLD (OPFS): <100MB - Binary data, ML models, static RDF chunksstoreInOPFS writes, but getOPFSUsage(): return 0 // Placeholder
    • FROZEN (FileSystem): Unlimited - User permission required for extended datasetsstoreInFileSystem(): throw new Error('FileSystem API storage not implemented')

    Where the effort actually went

    Matching a name someone typed to a name a botanist would accept is the part of this that resists a formula, so the scorer follows TNRS: partial match score PMS = 1 - (2 * ED / max(len(input), len(matched))), transformed through SNMStr = atan(2 * SNMS) / (2 * atan(2 * k)) + 0.5 with k of 2 for a binomial and 3 for a trinomial, and combined, where an author string is present, as OMS = 0.8 * snm_str + 0.2 * ams - p with a 0.1 penalty for unmatched text. Underneath it sit 1,079 correction rules in three kinds, because the arctangent cannot know that someone meant akebia.

    • epithet_correction825absinthifolia → absinthiifolia
    • genus_correction212acoelorrhaphe → acoelorraphe
    • binomial_reclassification42alebia quinata → akebia quinata

    v The file I wrote by hand

    I wrote one small RDF file for the cinnamons by hand, early on, while working out what the graph ought to look like. Each of the four species in it carries an rdfs:label, an origin, a flavour, a colour and a coumarin figure in g/kg, plus the adulterant edges that are the reason anyone cares which cinnamon they have in the jar. It is 218 lines holding 17 labels, and the 1,099,404-triple bundle the pipeline generated holds none.

    • Cinnamomum verumthe true one0.017 g/kgSri Lanka · Mild Sweet · Light reddish brown
    • Cinnamomum cassiamajor adulterant of C. verum0.31 g/kgChina · Bitter spicy · Dark reddish brown
    • Cinnamomum burmanniiminor adulterant of C. verum2.15 g/kgIndonesia · Spicy · Dark reddish brown
    • Cinnamomum loureiroiminor adulterant of C. verum6.97 g/kgVietnam · Sweet spicy · Dark reddish brown

    The file records C. cassia as a major adulterant of C. verum, with C. loureiroi and C. burmannii as minor ones, and declares C. zeylanicum and C. aromaticum as owl:equivalentClass synonyms of C. verum and C. cassia. The C. cassia node carries a powoNote reading "syn Neolitsea cassia in POWO". The coumarin figures are the reason the distinction is worth holding: 0.017 g/kg on the true cinnamon against 6.97 on the Vietnamese one, which is a difference no graph can pass on if it cannot name the node it is talking about.

    In the offline bundle, Cinnamomum verum J.Presl has 10 has_part edges pointing at ten opaque identifiers, three part names loose in free text (leaf, bark and root) with nothing tying one to the other, 6 contains edges and 29 vernacular ids that resolve to nothing. Walk to it above and those counts are all on the node, though the longest lists are shown as a sample with the true figure printed beside them.

    vi Where all of this comes from

    Every figure about the bundle itself was measured by scripts/extract-pfh-graph.py streaming the 30 .ntriples files in public/data/chunks of my plants-for-health-1 tree, and the walk reads the same file the copy was built from. Everything the page says about the rest of that tree was read straight off the files it names: the sizes, the Darwin Core row counts, the code quotations, the performance table, the ontology and the cinnamon numbers. Each one is cited beside the copy in this page's source. The manifest that describes the chunks was written 2025-09-06, and the last commit is a4cecf3, dated 2026-03-24, subject "feat(data): merge WCVP 1.4M names into FST — 79.3% match rate".

    The tree is 7.4 GB and 31,966 files outside .git, the biggest directories being rust-autosuggest at 1.4 GB and data at 1.0 GB. The ontology is two .ttl files, 258 lines between them, declaring rather more than ever got used: of everything in my own pfh: namespace, the only predicate that reaches the shipped bundle is pfh:hasVernacularName, labelled "has vernacular name", commented "Links a taxon to its vernacular/common names", with domain dwc:Taxon and range pfh:VernacularName.

    Source datasets named in the part URIs, with their counts on disk: UPWTA (Burkill, 2025) 29,582 · SEPASAL database. (RBG Kew, 1999) 9,542 · Duke's Phytochem. & Ethnobot. Databases. (Duke, 1992-2016) 5,656 · MPNS Import 3,342 · GRIN (USDA) 391 · Ethnobot. & Nutritive Potential of Wild Food Pl. (Shad et al., 2013) 41. Every one of the 180,054 vernacular rows in the Darwin Core export carries the same licence, CC BY-NC-SA 3.0, and the largest single source behind them is GRIN (USDA). This is my own prototype and it carries no endorsement from Kew or any of those datasets.

    contains here is a database assertion about chemical composition copied from those sources, and nothing on this page is a claim about treating anything.

    Two files in that tree look like real data and are not: data/taxa.json and public/data/taxa-health-full.ttl are the same three hard-coded fixture taxa from create_sample_data, the .ttl carrying dosages and contraindications with no source behind them. I have kept both off this page except to say what they are.

    ← back homeDR McCulloch · plants-for-health-1