Semantic review
create_sdp() gives you a package that is review-ready, not finished. Two calls take it the rest of the way, and both print the exact call for the next step — you paste it, and the script you end up with is the record of what you decided.
A package can reach validate_salmon_datapackage(require_iris=True) without opening a single file in a spreadsheet. Editing metadata/*.csv by hand still works and is still supported; it is the fallback rather than the recommended path, because a spreadsheet edit leaves no record of why a term was chosen, and that record is the most valuable thing the review produces.
Decide the semantic IRIs
review_semantics() builds a re-runnable queue from suggestions that already exist: one entry per unfilled semantic slot, each with its ranked shortlist and the exact call that decides it.
from metasalmonpy import review_semantics
review = review_semantics(package_path)
review── spawners · spawner_count · variable ───────────────────────────────────────
field: column_dictionary.csv · term_iri
current: REVIEW:https://w3id.org/smn/SpawnerAbundance
[1] Spawner Abundance smn score 4.9
The number of mature salmon returning to spawn in a stream.
https://w3id.org/smn/SpawnerAbundance
review = accept_suggestion(review, "spawner_count", "variable", rank=1)
review = reject_suggestion(review, "spawner_count", "variable") # no candidate fits
… one block per unfilled slot, elided here …
── spawners · entity ─────────────────────────────────────────────────────────
field: tables.csv · observation_unit_iri
current: <blank>
[1] Spawner Abundance smn score 4.9
The number of mature salmon returning to spawn in a stream.
https://w3id.org/smn/SpawnerAbundance
review = accept_suggestion(review, role="entity", rank=1, table="spawners")
review = reject_suggestion(review, role="entity", table="spawners") # no candidate fits
── next ──────────────────────────────────────────────────────────────────────
0 of 9 slots decided.
Paste the calls above into your script, then write the decisions:
apply_sdp_semantics("catch-sdp", review)
This never contacts a network or an LLM. It reads the semantic_suggestions attribute, or semantic_suggestions.csv, that suggest_semantics() / create_sdp() already produced. Where those suggestions carry a model’s assessment, ingested from your own harness, it shows it: an accept under the candidate it chose, any other decision once per slot and role. It never generates one.
review_semantics() accepts a written package path, a dictionary carrying the attribute, or the mapping infer_salmon_datapackage_artifacts() returns. include_filled=True also queues slots that already hold a final IRI or already carry a decision, max_candidates= bounds the shortlist of each target, which is one slot or, for a code value, one of its roles (None shows all), and columns= restricts the queue — a name matching no column is an error naming the columns that do exist, because filtering first and reporting an empty queue told a user who mistyped a column name that their package was finished.
Accept, or reject with a reason
Both take a review and return a new one, so a whole review is an ordinary, re-runnable script. Nothing is written until apply_sdp_semantics() runs.
from metasalmonpy import accept_suggestion, apply_sdp_semantics, reject_suggestion
review = accept_suggestion(review, "spawner_count", "variable", rank=1)
review = reject_suggestion(
review,
"spawner_count",
"unit",
reason="Counts of fish are dimensionless; no QUDT unit applies.",
)
apply_sdp_semantics(package_path, review)The address arguments follow the slot, and review_semantics() always prints the right spelling: omit column for a table-level slot and pass table= instead; pass table= as well when a column name appears in more than one table; pass code_value= for a codes.csv slot, and code_value="" for a column’s own slot when that column’s codes have slots with the same role, as a measurement column’s codes do. Where the right term exists but retrieval never surfaced it, accept it directly with accept_suggestion(review, "spawner_count", "variable", iri="…").
reason= is written into the package, as semantic_suggestions.csv’s decision_reason column, and review_metadata() reads it back onto the gap the rejection left:
unit_iri: required IRI and not yet decided
you rejected every candidate here: Counts of fish are dimensionless; no
QUDT unit applies.
Without it that gap comes back looking exactly like one nobody ever considered.
apply_sdp_semantics() is surgical and safe to re-run: it strips the REVIEW: prefix from the fields you decided, leaves undecided slots and their markers alone, and does not touch a byte of your data CSVs. The metadata CSVs, semantic_suggestions.csv and datapackage.json install as one transactional set, because the descriptor duplicates the dictionary’s IRI fields and a half-applied edit would leave the package quietly self-inconsistent. Decisions persist, so you can stop and pick the review up tomorrow without being asked the same questions again.
Fill in the free text, and the slots with no candidates
review_metadata() reads the package against the rules that actually decide strict validation rather than against a shortlist, so it sees something the semantic review structurally cannot: a required field that no candidate was ever found for. review_semantics() builds its queue from retrieved suggestions, so a slot nothing was found for never appears there; an empty shortlist and a full one look the same to review_metadata().
from metasalmonpy import review_metadata, set_sdp_dataset
review_metadata(package_path)── dataset.csv ───────────────────────────────────────────────────────────────
description: placeholder text, refused by strict validation
MISSING DESCRIPTION: describe the contents and purpose of dataset
'demo-1'.
creator: placeholder text, refused by strict validation
MISSING METADATA: add creator, team, or originating program.
set_sdp_dataset("catch-sdp",
description="<describe the contents and purpose of dataset 'demo-1'>",
creator="<add creator, team, or originating program>"
)
… one block per file and row with outstanding fields …
── next ──────────────────────────────────────────────────────────────────────
13 fields still block strict validation.
Replace each <...> with the real value, then paste the calls above.
4 of them are IRIs -- review_semantics() shows candidates for any that have them.
Then: validate_salmon_datapackage("catch-sdp", require_iris=True)
What it reports: unresolved MISSING DESCRIPTION: / MISSING METADATA: / REVIEW REQUIRED: placeholders; schema-required fields (constraints.required) that are blank, where a column the file does not have counts as blank in every row; any schema-declared *_iri field of tables.csv, column_dictionary.csv or codes.csv still carrying an unresolved REVIEW: marker; measurement columns missing term_iri, property_iri, entity_iri or unit_iri; and tables.csv rows with a blank observation_unit_iri. It never contacts an LLM. Under the shipped schema settings it never contacts a network either, because the schema it reads constraints.required from is the copy bundled with metasalmonpy. If you select a different schema with set_sdp_schema_source() or set_sdp_schema_base_url(), or with the METASALMONPY_SDP_SCHEMA_SOURCE or METASALMONPY_SDP_SCHEMA_BASE_URL environment variable, it reads that one, as the package writers do, and so do the setters below.
The four setters
from metasalmonpy import set_sdp_code, set_sdp_column, set_sdp_dataset, set_sdp_table
set_sdp_dataset(
package_path,
description="Spawner counts for two Vancouver Island streams, 2024.",
creator="Salmon Data Mobilization",
contact_name="Data steward",
contact_email="steward@example.org",
license="CC-BY-4.0",
)
set_sdp_table(
package_path,
"spawners",
description="One row per stream per survey year.",
observation_unit="A stream-year spawner count.",
observation_unit_iri="https://w3id.org/smn/Observation",
)
set_sdp_column(
package_path,
"spawner_count",
table="spawners",
column_description="Count of mature spawners observed in the stream.",
unit_iri="https://qudt.org/vocab/unit/INDIV",
)
set_sdp_code(
package_path,
"stream_name",
"Goldstream",
table="spawners",
code_description="Goldstream River, Vancouver Island.",
)Each addresses one row, writes the named fields into it, and keeps datapackage.json in step in the same transactional write. Every field the SDP schema declares for the file can be set: the ones most often unfilled are named arguments for discoverability, and the rest pass through **extras and are checked against the schema, so a misspelled licence= is an error rather than a silent no-op. Pass None for a field you are not setting, pandas.NA to clear one deliberately; a blank string is refused as ambiguous.
Pasting a printed call unedited is refused. A package whose creator reads <add creator, team, or originating program> would pass strict validation while saying nothing, which is worse than the placeholder it replaced, because the marker is gone.
Use set_sdp_column(..., term_iri="…") for a slot retrieval found no candidate for, and accept_suggestion() when there is a shortlist to choose from.
Then strict validation passes
When review_metadata() reports nothing outstanding:
from metasalmonpy import validate_salmon_datapackage
validate_salmon_datapackage(package_path, require_iris=True)chat_decomposition(), the interactive whole-variable dialogue, is deprecated with the rest of the in-package model call: it raises LLMDeprecationWarning and is removed in 0.7.0.
Have your own harness judge the shortlists
Model judgement runs outside metasalmonpy. The package writes a review packet, your own harness reads it and writes its answers to a CSV, and the package reads them back. Neither step calls a model; the only network either one reaches is vocabulary search, through search_fn. metasalmon writes the same packet, byte for byte apart from its producer member, so one harness serves both packages.
from metasalmonpy import write_semantic_review_packet
packet = write_semantic_review_packet(package_path, context_files=["methods-note.md"])
packet["path"] # <package>/review/semantic-review-packet.json
packet["packet_id"] # the SHA-256 the ingester checksThe packet holds exactly the slots review_semantics() would queue, each with its shortlist retrieved again (top_n=5 by default) and the evidence the package’s own validators read: label, IRI, source, native type, role hints, definition and scores. It also holds the measurement bundles with their current slots, scored excerpts from your context files, the review instructions, and the 30-column answer schema, which names an owner for every column. It is written under review/, which no publication path reads.
The harness answers every slot with one of five decisions: accept (giving the candidate’s index and echoing its IRI), review, retry_search (with a short query), reject_shortlist or request_new_term. It writes them, with a CSV library, to review/semantic-assessments-pass-1.csv, and writes the packet’s packet_id to the one-line sidecar semantic-assessments-pass-1.csv.packet-id beside it. The packet’s instructions member is the full brief. The shape of a harness, with the judgement left to yours:
import csv
import json
from pathlib import Path
review = json.loads(Path(packet["path"]).read_text(encoding="utf-8"))
columns = [column["name"] for column in review["output"]["columns"]]
answer = Path(package_path) / review["output"]["file"]
with open(answer, "w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=columns)
writer.writeheader()
for unit in review["units"]:
for slot in unit["slots"]:
row = dict.fromkeys(columns, "")
row.update({name: value or "" for name, value in slot["identity"].items()})
row.update(llm_provider="my-harness", llm_model="my-model")
# judge() is yours. It returns llm_decision and llm_confidence, and
# for an accept llm_selected_candidate_index and llm_selected_iri.
row.update(judge(unit, slot))
writer.writerow(row)
Path(f"{answer}.packet-id").write_text(review["packet_id"] + "\n", encoding="utf-8")Then read the answers back:
from metasalmonpy import ingest_semantic_assessments, semantic_llm_assessments
result = ingest_semantic_assessments(package_path)
result["status"] # "complete", or "awaiting_pass_2"
assessments = semantic_llm_assessments(package_path)The ingester checks the file against the packet it names, then each row. An IRI the packet did not offer is recorded as an error and never applied, a candidate index that is not a whole number is refused, and a final reject_shortlist is escalated to request_new_term, so the ontology gap is filed rather than lost. The record is kept in review/semantic-llm-assessments.csv. A retry_search whose search finds new candidates writes a second packet, review/semantic-review-packet-pass-2.json: answer it in semantic-assessments-pass-2.csv the same way and call ingest_semantic_assessments() again. Nothing from that slot is merged until then, and there is no third pass.
Nothing the harness writes reaches the metadata. The ingester never touches the metadata CSVs; you confirm each answer in the review queue, where the harness’s verdict now sits under the candidate it chose:
── spawners · spawner_count · variable ───────────────────────────────────────
field: column_dictionary.csv · term_iri
current: REVIEW:https://w3id.org/smn/SpawnerAbundance
[1] Spawner Abundance smn score 4.9
The number of mature salmon returning to spawn in a stream.
https://w3id.org/smn/SpawnerAbundance
llm: accept (confidence 0.9)
Matches the column's definition.
review = accept_suggestion(review, "spawner_count", "variable", rank=1)
A decision other than accept is printed once, under current:, because it judges the whole shortlist. Accept or reject as before, then apply_sdp_semantics(package_path, review).
For a dictionary you have not written yet, pass it in place of the path, with a review_dir for the session’s files: write_semantic_review_packet(dictionary, review_dir="review"), then ingest_semantic_assessments(dictionary, review_dir="review"). Its result["dictionary"] carries the assessments, so review_semantics() and apply_semantic_suggestions(..., strategy="llm") read them from there.
Deterministic suggestions
suggest_semantics() searches for candidates for dictionary, code, table, and dataset targets. Measurement columns use six ordered semantic roles: variable, property, entity, unit, constraint, and statistical modifier.
There is no dictionary method slot. sdp-0.3.0 removed it: a method is how a value was produced, not part of what the value is, so it moved to the three placements migrate_sdp_methods() relocates it to. The method role survives only for codes.csv term targets — a code value naming a procedure.
from metasalmonpy import infer_dictionary, semantic_suggestions, suggest_semantics
dictionary = infer_dictionary(
observations,
dataset_id="catch-monitoring",
table_id="observations",
)
reviewed = suggest_semantics(
observations,
dictionary,
sources=["smn", "gcdfo"],
max_per_role=3,
)
suggestions = semantic_suggestions(reviewed)When sources is omitted, metasalmonpy chooses role-aware defaults. An explicitly supplied source list is a strict allowlist for initial and retry retrieval. For example, sources="smn" cannot introduce QUDT candidates.
semantic_suggestions() and its companion semantic_llm_assessments() are the supported way to read what a stage attached, so review tooling never reaches into df.attrs itself. Both accept a dictionary, an artifact mapping, or a written package path, and both return None rather than an empty frame when nothing is attached — they replace a raw attrs lookup and must answer an is None test the same way it did. For a package path, semantic_llm_assessments() returns the record ingest_semantic_assessments() kept in review/semantic-llm-assessments.csv, and None before any has been ingested: the in-package call’s assessments are not written into the package.
The in-package model call is deprecated
llm_assess=True, the eleven llm_* arguments and chat_decomposition() raise LLMDeprecationWarning, a FutureWarning, once per call, and are removed in 0.7.0. Use your own harness instead. Until then the call still works, and is still strictly opt-in: context does not enable it, and you must set llm_assess=True explicitly. Context file inputs must be local paths, not parsed pandas, XML, or document objects.
reviewed = suggest_semantics(
observations,
dictionary,
sources=["smn"],
llm_assess=True,
llm_provider="openrouter",
llm_model="openai/gpt-5.4-mini",
llm_context_files=["data_dictionary.csv"],
)
assessments = semantic_llm_assessments(reviewed)Supplying llm_context_files or llm_context_text while llm_assess=False produces a warning and makes no provider request.
Bundle review
Measurement targets are reviewed as one column bundle. The request contains the original dictionary row, existing semantics, value metadata, all six candidate slots, relevant context, and the retrieval-source policy.
The adapter preserves the public per-target format:
semantic_suggestionsremains the candidate table;semantic_llm_assessmentsalways uses the same 30-column schema;- positional candidate indices remain available to callers;
- provider failures retain the deterministic shortlist; and
- malformed individual slots fall back without discarding valid sibling slots.
Retry processing is bounded to one round. An exact retry of the original query is preserved as a retry_search decision, marked with llm_retry_query_rejection_reason="duplicate_original_query", and skipped. Near-duplicate queries remain eligible. Escalated shortlist rejections retain llm_escalated_from="reject_shortlist".
Deterministic validators
Pure validators can downgrade unsupported accept decisions to review for:
- missing method evidence;
- missing constraint evidence;
- role and ontology-type incompatibility;
- deterministically known unit/property dimensional conflicts; and
- curated semantic redundancy.
A downgrade clears the selected candidate but preserves model confidence and rationale as provenance. Validators never retrieve, substitute, or invent terms.
Applying a whole batch at once
review_semantics() decides one slot at a time and is the recommended path. apply_semantic_suggestions() is the batch alternative, for a dictionary you have not written yet:
from metasalmonpy import apply_semantic_suggestions
updated = apply_semantic_suggestions(
reviewed,
strategy="llm",
roles=["variable", "property", "entity", "unit"],
min_llm_confidence=0.8,
)strategy="top" keeps the original lexical ranking and takes the first candidate that passes the filters, strategy="llm" takes the LLM-selected accepts, and strategy="reviewed" takes only rows whose decision column says accepted — the shape apply_sdp_semantics() writes. "reviewed" is exempt from the unattended auto-apply gate, and deliberately: that gate is a lexical compatibility heuristic for deciding whether a seeded top-1 hit is safe to write into a dictionary nobody has looked at, and a human who read the definition and said yes is the opposite situation. Running it there let a regex silently overrule an explicit decision, with nothing said beyond a count of rows that “did not meet the requested filters” (metasalmon backlog #118).
The four core roles — variable, property, entity, unit — are eligible for automatic prefill in create_sdp() whenever a compatible candidate is found. Constraint and statistical modifier are eligible too, but only on the deterministic path and only when the column’s own name, label or description carries the evidence for the qualifier or the aggregation. The retrieval offering a hit is never enough on its own: a mean_wild_spawner_count column earns both slots, a bare count column earns neither, whatever comes back from search.
Under LLM review (llm_assess=True) the two qualifier roles are excluded outright, as the roles= argument above shows. A reviewed accept is not a licence to auto-apply the roles that change what the variable is: a mean weight and a maximum weight are different variables, which is why sdp-0.3.0 moved the modifier into the dictionary in the first place.
Everything prefilled is marked REVIEW:, including the two qualifier slots. That marking is the point rather than a detail — an unmarked constraint IRI is an unreviewed assertion sitting in a slot you never asked about.
This page said the opposite until 2026-08-24: that only the four core roles were eligible and constraint and statistical modifier were left for explicit review. That was a true description of this package and a false one of metasalmon, whose deterministic path had always applied them under the same evidence gates — and each side’s prose matched its own code, so no reader was positioned to catch it. Hub Q16 ruled metasalmon right (“Yeah lets go the R way”) and this package moved. See PARITY.md row 57.
The spreadsheet fallback
Editing metadata/*.csv in a spreadsheet still works and is still supported. Save the files back as CSV and reload with read_salmon_datapackage().
It is the fallback for one reason: the only record of a decision made that way is the mutated CSV. There is nothing saying which candidates were considered, on what evidence the chosen one won, or that a slot was left blank deliberately — which makes it the one unreproducible link in a chain that is otherwise byte-reproducible, C-collated, hash-verified and guarded. apply_sdp_semantics() and the set_sdp_*() setters write straight into the package, so with them there is nothing to save or reload either.
README-review.txt in the package folder carries the same checklist, and metadata/column_dictionary.csv is a reasonable context_files input to write_semantic_review_packet() when you want your harness to reason from the package itself rather than a separate methods note. Pass the path string, not a DataFrame read from it.
Assemble the reviewed closure
Strict validation is not the publication gate. A package that passes validate_salmon_datapackage(require_iris=True) can still be refused by write_eml_from_sdp() and publish_sdp_to_knb(), because the EML/KNB path additionally requires a reviewed closure: a standing record of which vocabulary terms you accepted, on what evidence, and for which target.
Three files carry it. write_sdp_semantic_closure() writes the first two and pins the digests the third one carries; the reviewed EML facts in the third are yours.
| File | What it must contain | Written by |
|---|---|---|
metadata/semantic_vocabulary.csv |
One evidence row per canonical measurement IRI — exactly that set, no more and no less | write_sdp_semantic_closure() |
reviewed_semantic_selections.csv |
Exactly one accepted row per canonical review target |
write_sdp_semantic_closure() |
metadata/eml-mapping.yml |
The reviewed EML facts, plus the SHA-256 of each of the two files above | you, except the two SHA-256 values |
The canonical measurement IRI set is every distinct non-empty IRI across term_iri, property_iri, entity_iri, constraint_iri, statistical_modifier_iri and unit_iri on your measurement dictionary rows, plus any tables.csv method_iri and any procedure IRIs reached through codes.csv from an observation-structures binding. A semicolon-separated constraint_iri contributes each distinct IRI, not one combined string.
The canonical review target set is derived independently, and it is not the measurement set plus anything. It has one target for each distinct non-empty IRI in each of the same six semantic fields on your measurement dictionary rows, plus each distinct non-empty IRI in each table’s observation_unit_iri and method_iri. In particular, two different IRIs in one constraint_iri cell require two accepted ledger rows for that field.
The two sets differ in both directions, which is why neither can be reasoned from the other:
- an
observation_unit_iriis a review target and not a vocabulary term — a reviewed selection, but not something the EML measurement path emits; and - a
sosa:usedProcedurereached through a code value is a vocabulary term and not a review target — the EML method path emits it, but the ledger has no slot for it, because it is reached through the code values in your data rather than through any field the review target set reads. A reviewer can still select the code value’sterm_iriincodes.csv.
Getting that second direction backwards is worth avoiding rather than discovering: the ledger gate accepts exactly the canonical target set, so a row added for a code-resolved procedure is refused. If you update the sidecar’s ledger SHA-256 too, write_eml_from_sdp() and publish_sdp_to_knb() stop and name the row as unexpected; without that update, they stop at the digest check. validate_salmon_datapackage(require_iris=True) goes on passing because only the reviewed export reads the ledger. The package stops being publishable because of a row you added to make it publishable.
Produce both files
write_sdp_semantic_closure() derives both canonical sets, re-runs find_terms() for each IRI to resolve its evidence, computes every reviewed_snapshot_sha256, writes both files, and rewrites the two file digests in metadata/eml-mapping.yml. No SHA-256 in a reviewed package is ever hand-written.
Two kinds of value it cannot derive, and both go in evidence:
- Vocabularies
find_terms()cannot search. It resolvessmnandgcdfo. A QUDT unit row is hand-authored in full. It also cannot fillnative_typeorsource_url, which describe the ontology artifact rather than the term; those are derived from the resolved source and can be overridden. confidenceandreview_rationale, which are your judgement. They are read fromsemantic_suggestions.csvwhere a decision reason was recorded against an accepted IRI, and otherwise written as aREVIEW REQUIRED:marker with a warning naming each target that got one. Replace those before publishing. A rejection reason is not one of them.reject_suggestion(reason=…)writesdecision_reasonon a row whosedecisionisrejected, and the producer keeps only blank andacceptedrows, so that text never becomes areview_rationale— which is right, since it says why a candidate was not the term rather than why the IRI you then supplied is. Its reader isreview_metadata(), as above. And becauseaccept_suggestion()records no reason at all today, in practice expect the marker and supplyreview_rationalethroughevidence.
import pandas as pd
from metasalmonpy import write_sdp_semantic_closure
# Your judgement on each accepted IRI. The term evidence is resolved for you.
judgements = pd.DataFrame([
{
"iri": "https://w3id.org/gcdfo/salmon#SpawnerAbundance",
"confidence": "high",
"review_rationale": "The column is an escapement estimate of adult spawners.",
},
{
"iri": "https://w3id.org/smn/Observation",
"confidence": "high",
"review_rationale": "Each row is one population-year observation.",
},
])
# A QUDT row is hand-authored end to end: the search cannot reach QUDT.
qudt = pd.DataFrame([{
"iri": "https://qudt.org/vocab/unit/INDIV",
"label": "Individual",
"definition": "A counting unit denoting one organism.",
"source": "qudt",
"ontology": "qudt",
"resource_kind": "Unit",
"type_iris": "http://qudt.org/schema/qudt/Unit",
"native_type": "qudt:Unit",
"source_url": "https://qudt.org/vocab/unit/",
"confidence": "high",
"review_rationale": "Values are whole counts of organisms.",
}])
closure = write_sdp_semantic_closure(
"path/to/package",
evidence=pd.concat([judgements, qudt], ignore_index=True),
)Copy the reviewed EML sidecar template before running this, so the two digests are pinned in the same pass. If you produce the closure first, run it again once the sidecar exists.
Three outcomes, and only one of them is an ontology gap
An IRI it cannot resolve is reported, not guessed at, and not fatal. closure["gaps"] is a term-gap table in the same shape detect_semantic_term_gaps() returns, so it feeds the request pipeline directly:
from metasalmonpy import render_ontology_term_request, submit_term_request_issues
if len(closure["gaps"]) > 0:
requests = render_ontology_term_request(closure["gaps"], ask=False)
submit_term_request_issues(requests, dry_run=True)The unresolved row is left out of the vocabulary rather than invented, and the omission is not silent: attempting EML export names the same IRI as missing.
The other two ways a row can go unwritten are not gaps, because in neither case is the term known to be absent — and reporting either as a gap would ask an ontology to mint a term nobody established was missing:
- A lookup that did not answer — a search that raised, or a
find_terms()result whoseattrs["diagnostics"]records a source that failed. Nothing has been learned about that IRI, so the call raisesRuntimeErrorand writes nothing, naming each IRI and the sources that did not answer. The remedy is to re-run; leaving a half-derived closure on disk would make the retry start from worse state. - A term found with incomplete evidence — an exact IRI hit carrying a blank required field, which a class with no definition is the ordinary case of. The row appears in
closure["incomplete"], naming the missing field and the slot, with a warning; every other row is still written. Supply the field throughevidence, or annotate the term in its ontology.
Reading a package with an existing closure, the whole thing round-trips: the two files this writes are exactly the two write_eml_from_sdp() and publish_sdp_to_knb() read back.