Skip to content

feat(schemaview): TermDescriptor and RangeInfo::is_numeric() - #113

Merged
kervel merged 2 commits into
mainfrom
feat/term-descriptor-and-numeric-ranges
Aug 25, 2026
Merged

feat(schemaview): TermDescriptor and RangeInfo::is_numeric()#113
kervel merged 2 commits into
mainfrom
feat/term-descriptor-and-numeric-ranges

Conversation

@kervel

@kervel kervel commented Aug 25, 2026

Copy link
Copy Markdown

Two additions requested by a downstream consumer that currently reimplements this logic locally
because it is private here. Both are things this crate wants anyway.

1. TermDescriptor — the value→term decision, without a value

The turtle writer spelled out the same precedence chain at three call sites (single value, list
item, mapping value): reference-or-IRI-range → enum meaning → literal. Each copy was ~20 lines of
triple building, and the chain's ordering invariant was stated nowhere.

SlotView::term_descriptor(&Converter) resolves that chain from the slot alone — an enum meaning
map, an IRI disposition, a datatype, a language tag — and returns None when the values are not a
term anything can reproduce (an inlined structure serializes as a blank node whose label nothing
can reproduce). turtle::term_for(&descriptor, value, conv) applies one to a single value.

The three call sites collapse to one line each; turtle.rs is 41 lines shorter.

Writing the chain once also makes an invariant visible that was previously accidental: the writer
tested is_range_iri before the enum meaning, while the order is in fact immaterial, because
determine_rdf_type_info yields (None, false) for an enum range so is_range_iri is never true
for one.

Two deliberate deviations from the shape that was requested:

  • term_for is a runtime function, not a TermDescriptor method. schemaview depends on
    neither oxrdf nor serde_json and shouldn't start; the descriptor is schema knowledge, the
    Term is not.
  • term_descriptor takes the converter rather than deriving one internally, so enum meaning
    CURIEs expand exactly as they do during serialization. Deriving one would diverge for a schema
    with conflicting prefixes — which this repo has a test for.

2. RangeInfo::is_numeric()

The question a consumer asks when deciding whether a column's values compare as numbers or as
text: '9' >= '10' holds as text and fails as a number, so getting it wrong is silent in both
directions.

is_integer/is_floating_point cannot answer it. They pick a JSON canonicalisation at boxing
time, and between them cover exactly integer, float, double, decimal — so a slot declared
xsd:int, xsd:long, xsd:short, xsd:byte, any xsd:unsigned* or any bounded-integer datatype
reads as non-numeric. Both are left exactly as they are; their callers want the narrower question.

is_numeric uses the same IRI-primary, builtin-name-fallback resolution, so it also catches
schema-defined subtypes and still works against a schema whose linkml:types is not loaded.

Testing

  • One case per precedence rule against the descriptor (schemaview/tests/term_descriptor.rs),
    including the enum with no meaning (must stay a literal) and a slot declaring both
    in_language and a datatype (the datatype must win).
  • The exact Term per rule through term_for
    (runtime/tests/term_descriptor_parity.rs), on the same fixtures the golden turtle tests use —
    including the mixed enum whose unknown value has no meaning, and a CURIE-valued IRI range.
  • The existing turtle serialisation tests pass unchanged, which is what makes the first half a
    refactor rather than a rewrite.
  • One slot per XSD numeric datatype for is_numeric, plus the negatives (string, boolean, date,
    uriorcurie, a class range, an enum range) and a schema with no types: block at all, which is
    the case a hand-kept list of datatype IRIs gets wrong.

cargo fmt --all --check, cargo clippy ... -D warnings, cargo test --workspace (73 suites) and
the stub_gen check are all clean.

Not in this PR

  • turtle.rs still has two literal_and_type call sites (~lines 555, 611) that make a
    datatype-only version of the same decision for top-level collections of bare scalars emitting
    rdf:value. Routing those through the descriptor would change serialisation output (they would
    start honouring language tags, enum meanings and IRI ranges), so it wants its own change.
  • try_lang_tag_collapse — the jsonld:language/jsonld:value class pattern — still collapses an
    inlined object into a language-tagged literal outside the descriptor. The descriptor returns
    None for inlined ranges, so a consumer refuses those columns rather than disagreeing.

Frank Dekervel and others added 2 commits August 25, 2026 10:40
Answers the question a consumer asks when deciding whether a column's values
compare as numbers or as text: '9' >= '10' holds as text and fails as a number,
so getting it wrong is silent in both directions.

is_integer/is_floating_point cannot answer it. They were written to pick a JSON
canonicalisation at boxing time, and between them they cover exactly integer,
float, double and decimal -- so a slot declared xsd:int, xsd:long, xsd:short,
xsd:byte, any xsd:unsigned*, or any of the bounded-integer datatypes reads as
non-numeric. Those four names stay as they are: their callers want the narrower
question.

is_numeric uses the same IRI-primary, builtin-name-fallback resolution: the
resolved datatype IRI also catches schema-defined subtypes, and the builtin
LinkML type names keep detection working against a schema whose linkml:types is
not loaded -- which is the case a consumer keeping its own list of datatype IRIs
gets wrong.

LinkML has no builtin name for the XSD numeric datatypes beyond those four, so a
schema can only reach them by declaring its own type; the test fixture does that
once per datatype and asserts detection comes off the IRI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The turtle writer spelled out the same precedence chain at three of its call
sites (single value, list item, mapping value): reference-or-IRI-range, then
enum meaning, then literal. Each copy was ~20 lines of triple building, and the
chain's ordering invariant was nowhere stated.

Lift the decision to SlotView::term_descriptor(), which resolves it from the
slot alone -- an enum meaning map, an IRI disposition, a datatype, a language
tag -- and returns None when the values are not a term anything can reproduce
(an inlined structure serializes as a blank node whose label nothing can
reproduce). turtle::term_for() applies a descriptor to one value, so the three
call sites collapse to one line each and turtle.rs loses 41 lines net.

Writing the chain once also makes an invariant visible that was previously
accidental: the writer tested is_range_iri before the enum meaning while the
order is in fact immaterial, because determine_rdf_type_info yields
(None, false) for an enum range and so is_range_iri is never true for one.

The split across crates is deliberate. The descriptor is schema knowledge and
lives in schemaview; term_for stays in the runtime because schemaview depends on
neither oxrdf nor serde_json and should not start. Consumers that must render
values without serializing them -- pushing a query down to SQL over stored JSON,
where the rendering is decided at plan time and has to match this writer term
for term -- need the descriptor, not the Term.

term_descriptor takes the converter rather than deriving one, so the enum
meaning CURIEs expand exactly as they do during serialization; deriving one
internally would diverge for a schema with conflicting prefixes.

Tests: one case per precedence rule on the descriptor (schemaview), the exact
Term per rule through term_for (runtime, on the same fixtures the golden turtle
tests use, including the mixed enum whose `unknown` value has no meaning and a
CURIE-valued IRI range), and the existing turtle serialisation tests passing
unchanged -- which is what makes this a refactor rather than a rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@kervel
kervel merged commit c71a63f into main Aug 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant