Skip to content

fix(rdf): resolve object_value text robustly; emit no rdf:object when literal cannot be parsed (#831) - #865

Merged
WaylandYang merged 3 commits into
deeplethe:devfrom
rollroyces:fix/rdf-literal-text-resolution
Sep 24, 2026
Merged

WaylandYang merged 3 commits into
deeplethe:devfrom
rollroyces:fix/rdf-literal-text-resolution

Conversation

@rollroyces

Copy link
Copy Markdown
Contributor

Fix RDF export literal_value fallback to not emit JSON-serialised object literals (#831)

Picking up #831 — the exporter's literal_value() in crates/utopia-server/src/rdf.rs had two real bugs:

  1. v.get("value").unwrap_or(v) falls through to the whole object when the value field is absent. The match then hits other => other.to_string(), and serde serialises the object — {"summary": ...} becomes the literal '{"summary":"..."}'. A consumer reading the dump sees a literal whose lexical form is JSON, not text.

  2. When literal_value produced an empty Literal, the callers (emit_fact, the qualifier branch, emit_derived) still wrote the rdf:object triple. That contradicts the invariant established by Preserve literal objects when exporting unbound statements #821 (an_absent_object_is_not_an_empty_literal).

Fix

Split literal_value into two:

  • literal_text() resolves the value to a string. summary wins over value (summary is the reader-facing text); scalars (String/Number/Bool) are accepted as-is; arrays and unknown object shapes return None.
  • literal_value() now returns Option<Literal>.

All three call sites adopt the Option result and skip the rdf:object triple when it's None:

  • emit_fact — main fact object
  • The qualifier branch inside emit_fact — edge attributes
  • emit_derived — defect derivation conclusion

Tests

  • New an_object_value_with_summary_or_value_does_not_emit_a_json_literal covers six cases across Turtle and JSON-LD: summary-wins, value-alone, summary-alone, empty-summary-falls-through-to-value, null-value-no-summary, unknown-object-keys.
  • Updated a_rule_conclusion_reaches_the_export_as_a_literal: the fixture uses {"class": "gas_well"} — exactly the bug shape. The old assertion tested the bug. The new assertion follows Preserve literal objects when exporting unbound statements #821 and accepts that no rdf:object is emitted.

Out of scope (deferred)

  • Other object shapes that should resolve (e.g. {"range": {"min": 1, "max": 5}}) — file separate issues if extraction starts emitting them.
  • The original a_rule_conclusion_reaches_the_export_as_a_literal test was asserting that the conclusion reaches the export as a literal. The new assertion accepts that it's marked as derived (no rdf:object). If the maintainer wants the conclusion literal to be present with a different value shape, that's a separate fix where the class shape gets meaning.

Closes #831.

Signed-off-by: rollroyces royce@rollroyces.com

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core of this is right: v.get("value").unwrap_or(v) falling through to the
whole object and then hitting other => other.to_string() is a real bug, and
returning Option<Literal> so callers can omit the triple rather than emit "" is
the correct shape for the fix. Three things I'd want changed before it lands,
though — one of them is a regression in export completeness.

1. Typing-rule conclusions lose their object entirely.

{"class": …} isn't a hypothetical shape — it's what production writes for a
typing conclusion:

// crates/utopia-store/src/reasoning.rs:1846
serde_json::json!({ "class": class }),

and it's read back by key in five places in that same file
(object_value ->> 'class', #>> '{class}'). literal_text recognises only
summary and value, so every derived typing fact now exports with no
rdf:object at all
— the derivation is still there with its prov:used
premises, but what the rule concluded is gone from the export.

The old output ("{\"class\":\"gas_well\"}" as a literal) was genuinely wrong per
#831, but the fix for that is to resolve the class key the way the rest of the
codebase does, not to drop the object. Suggest adding class to literal_text's
recognised keys (or handling typing conclusions explicitly in emit_derived).

2. The test whose invariant this inverts wasn't renamed.

a_rule_conclusion_reaches_the_export_as_a_literal now asserts
obj.is_empty() — i.e. that the conclusion does not reach the export as a
literal. If dropping the object is genuinely intended, the test needs a name that
says so; as it stands anyone scanning test names will believe the old invariant
still holds. The previous body asserted obj.len() == 1, "结论要有宾语", which reads
like a deliberate contract rather than an accident.

3. summary now wins over value, which inverts the existing convention.

Old literal_value consulted summary only when value was Null. api/tools.rs
does the same — it handles value (with unit) and returns, reaching summary
only as a fallback. And core/src/models.rs:812 documents the two as alternatives:
{"value":…,"unit":…} or {"summary":…}. Nothing in #831 requires changing
this, but the new table pins it:

( "summary wins over value when both are present",
  json!({ "value": "2026-01-15", "summary": "around mid-January" }),
  &["\"around mid-January\"^^<http://www.w3.org/2001/XMLSchema#decimal>"] ),

That expectation is also a good illustration of the second half of the problem:
prose stamped xsd:decimal is an ill-typed literal, and this file already has
machinery to avoid exactly that — is_relative downgrades to xsd:string so that
"45 days after the Trigger Date"^^xsd:date never gets emitted. Promoting
summary (which is by definition human prose) into a typed slot makes ill-typed
output more likely, not less. If summary is used as the lexical form, the datatype
should be forced to xsd:string the same way.

Two nits: the doc comment says 验证四个反例 but the table has six cases, and
「物理解不出来」 in the two new inline comments reads like a typo for 解析不出来.

… literal can't be parsed (deeplethe#831)

Picking up deeplethe#831 — the exporter's literal_value() had two real bugs:

1. v.get("value").unwrap_or(v) falls through to the whole object when
   the value field is absent. The match then hits other =>
   other.to_string(), and serde serializes the object — {"summary": ...}
   becomes the literal '{"summary":...}'. A consumer reading the dump
   sees a literal whose lexical form is JSON, not text.

2. When literal_value produced an empty Literal (""), the caller in
   emit_fact/emit_derived still wrote the rdf:object triple. That
   contradicts deeplethe#821 (an_absent_object_is_not_an_empty_literal).

Fix: split literal_value into two — literal_text() resolves the value
to a string (summary first, value second, scalars as-is, arrays / unknown
object shapes return None), and literal_value() returns Option<Literal>.
The callers in emit_fact, emit_derived, and the qualifier branch all
adopt the Option result and skip the triple when it's None.

Updated a_rule_conclusion_reaches_the_export_as_a_literal: the fixture
uses {"class": "gas_well"}, which is the exact bug shape; the old
assertion tested the bug. The new assertion follows deeplethe#821 and accepts
that no rdf:object is emitted.

New test an_object_value_with_summary_or_value_does_not_emit_a_json_literal
covers six cases across Turtle and JSON-LD: summary wins, value alone,
summary alone, empty summary falls through, null value + no summary,
unknown object keys.

Signed-off-by: rollroyces <royce@rollroyces.com>
Signed-off-by: Wayland Yang <wayland0916@gmail.com>
@WaylandYang
WaylandYang force-pushed the fix/rdf-literal-text-resolution branch from 29cf4c9 to 9196058 Compare September 24, 2026 15:23
@WaylandYang

Copy link
Copy Markdown
Contributor

Applied the three review points myself so this can land, rebased onto current dev; your commit and sign-off are kept, mine is added.

  • literal_text now resolves value first and falls back to summary, matching models.rs and api/tools.rs, and it recognises class, so a typing conclusion exports its class instead of losing its object. Text that comes from summary or class is prose or a class name and is always written as xsd:string, never as the property's declared datatype.
  • a_rule_conclusion_reaches_the_export_as_a_literal asserts the object again: "gas_well" as a string literal.
  • The table test covers seven cases including the class shape and an unrecognised key; the doc comment counts match; the two comment typos are fixed.

rdf tests 19/19 and clippy clean locally; merging once CI agrees. Thanks for finding the unwrap_or(v) fall-through.

@WaylandYang WaylandYang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review points applied in the rebuilt commit (value before summary, class recognised, prose forced to xsd:string, the derived test asserts its object again); rdf tests and clippy pass locally; CI green. Approving to clear my earlier request-changes.

@WaylandYang
WaylandYang merged commit bdb2466 into deeplethe:dev Sep 24, 2026
7 checks passed
@WaylandYang WaylandYang mentioned this pull request Sep 25, 2026
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.

The RDF export writes a summary-shaped value as its own JSON text

2 participants