Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
cd09ce9
docs: design spec for inlined multivalued element identity
Kapersyx Aug 17, 2026
be40979
docs: correct per-example resolutions in identity design spec
Kapersyx Aug 18, 2026
0391a94
docs: implementation plan for inlined multivalued element identity
Kapersyx Aug 18, 2026
a9f6760
feat(schemaview): ClassView::unique_keys merged across is_a and mixins
Kapersyx Aug 18, 2026
673db04
docs: uniform keyed matching — deliberate compat break per spike find…
Kapersyx Aug 18, 2026
fdcd373
feat(runtime): diff.linkml.io/opaque stops recursion, replaces whole …
Kapersyx Aug 18, 2026
b228b4b
feat(runtime): patch refuses to descend below an opaque slot
Kapersyx Aug 18, 2026
32342e7
feat(runtime): match inlined list elements by unique_keys-derived ide…
Kapersyx Aug 18, 2026
c120110
docs(plan): identity-addressed lists resolve by label only on patch side
Kapersyx Aug 18, 2026
c4bffea
feat(runtime): patch resolves identity-label path segments, refuses a…
Kapersyx Aug 18, 2026
ae8e8d0
fix(runtime): unresolved list Update reports failed instead of appending
Kapersyx Aug 18, 2026
6d9f398
feat(runtime): opt-in element-identity linter (schema + instance)
Kapersyx Aug 18, 2026
b117812
fix(runtime): deterministic instance lint order, sharper lint messages
Kapersyx Aug 18, 2026
19dbbd9
feat(tools): --lint-identity flag on linkml-schema-validate
Kapersyx Aug 18, 2026
00a0dd5
fix(tools): stable machine-readable JSON shape for --lint-identity
Kapersyx Aug 18, 2026
2f4c6ac
fix(runtime): lint_element_identity visits each class once, returns s…
Kapersyx Aug 18, 2026
774b825
refactor(tools): drop CLI-side lint dedupe/sort
Kapersyx Aug 18, 2026
15641b2
fix(runtime): dedupe lint class visits by schema+name, not class_uri
Kapersyx Aug 18, 2026
2cfe8f2
fix(schemaview): resolve_schemas loops to fixpoint, resolves imports …
Kapersyx Aug 18, 2026
6a3999a
fix(tools): seed schema source path into import resolution
Kapersyx Aug 18, 2026
0215dbd
feat(runtime): lint reports inherited slots once, at the introducing …
Kapersyx Aug 18, 2026
2420aa0
feat(python): expose element-identity lint functions
Kapersyx Aug 18, 2026
181c343
docs(runtime): document opaque annotation and unique_keys path segments
Kapersyx Aug 18, 2026
3b2273d
fix(runtime): keep diff→patch round trip when only the target loses i…
Kapersyx Aug 19, 2026
8548fb5
fix(runtime): navigate_path resolves list segments by the shared rule
Kapersyx Aug 19, 2026
fc4a092
feat(runtime): warn when a class offers several unique_keys as identity
Kapersyx Aug 19, 2026
58c9e5d
fix(tools): report the shared problem-type label in the CLI's JSON
Kapersyx Aug 19, 2026
db41c05
docs(runtime): document order-dependent list resolution within one patch
Kapersyx Aug 19, 2026
6965b36
fix(python): navigate test uses identifier segment under label-addres…
Kapersyx Aug 19, 2026
e6a5fbf
feat(runtime): lint flags list identity derived from a type designato…
Kapersyx Aug 19, 2026
c1402a6
test(runtime): pin designator-key rule on inherited slot_usage shape
Kapersyx Aug 19, 2026
8639bdf
docs(spec): addendum — designator and canonicalization hardening rules
Kapersyx Aug 19, 2026
4e54fc4
docs(plan): addendum tasks 10-16 — designator/canonicalization hardening
Kapersyx Aug 19, 2026
a6c69f5
fix(runtime): a type designator is never an element identity
Kapersyx Aug 19, 2026
e5bb15a
docs(runtime): correct chokepoint scope, stale fixture comments, lint…
Kapersyx Aug 19, 2026
2b3f918
fix(schemaview): native class URI survives expansion in get_uri
Kapersyx Aug 19, 2026
11cf89d
fix(runtime): canonicalize designator values and IRI-expand identity …
Kapersyx Aug 19, 2026
b2f2cb6
fix(runtime): canonicalization cost, dict-arm class selection, segmen…
Kapersyx Aug 19, 2026
b82d629
fix(runtime): class change is whole-element replacement, patch fails …
Kapersyx Aug 19, 2026
7d4933a
feat(tools): linkml-patch surfaces failed delta paths
Kapersyx Aug 19, 2026
b1adf84
fix(tools): three-way exit contract for linkml-patch
Kapersyx Aug 19, 2026
894e279
docs(runtime): patch totality, rule-3 guard, cross-schema note
Kapersyx Aug 19, 2026
0e5dfd7
fix(tools): partial-application exit code moves off clap's usage code
Kapersyx Aug 19, 2026
357c307
fix(runtime): restore keyed-shape short-circuit, note cross-schema bu…
Kapersyx Aug 19, 2026
0d4a44e
fix(runtime): inlined-dict keys are injected and validated against pa…
Kapersyx Aug 19, 2026
5d50f77
fix(runtime): dict-key divergence compares post-canonical stored value
Kapersyx Aug 19, 2026
fd69e76
feat(runtime): identity lint covers missing labels, split label space…
Kapersyx Aug 19, 2026
fc3ce53
fix(runtime): identity lint gate subtraction, key-labelled split groups
Kapersyx Aug 19, 2026
3aaa676
feat(tools): --lint-identity flag on linkml-validate
Kapersyx Aug 19, 2026
6162bbc
docs: hardening close-out cross-check
Kapersyx Aug 19, 2026
8b2ea94
refactor(tools): hoist the CLIs' shared rendering helpers
Kapersyx Aug 19, 2026
930a1cb
fix(tools): a warning is not an error in linkml-validate
Kapersyx Aug 19, 2026
f2aefc4
fix(runtime): diff's changed-key check compares canonical identity
Kapersyx Aug 19, 2026
7163342
fix(runtime): rule 5's subject says which rule spoke
Kapersyx Aug 19, 2026
d8fb2d6
fix(runtime): the keyed segment resolver refuses ambiguity too
Kapersyx Aug 19, 2026
a4b2087
fix(tools): validate marks a warning as one wherever it prints it
Kapersyx Aug 19, 2026
a793ffb
fix(runtime): unresolved list Update re-adds when the payload names i…
Kapersyx Aug 21, 2026
3021b68
fix(runtime): allow clippy's argument-count lint on apply_list_leaf_d…
Kapersyx Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,675 changes: 1,675 additions & 0 deletions docs/superpowers/plans/2026-08-18-inlined-multivalued-element-identity.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

44 changes: 43 additions & 1 deletion src/python/python/linkml_runtime_rust/_native.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -2789,7 +2789,12 @@ class LinkMLInstance:
def __getitem__(self, key:typing.Any) -> LinkMLInstance: ...
def navigate(self, path:typing.Any) -> typing.Optional[LinkMLInstance]:
r"""
Navigate by a path of strings (map keys or list indices).
Navigate by a path of strings: slot names, mapping keys, and — for
lists — element identity labels (identifier/key or `unique_keys` value)
when the list carries unique labels, numeric indices otherwise. This is
the same addressing `diff` emits and `patch` applies, so delta paths are
navigable; a numeric segment aimed at a label-addressed list resolves to
nothing rather than to that position.
Returns a new LinkMLInstance if found, otherwise None.
"""
def keys(self) -> builtins.list[builtins.str]: ...
Expand Down Expand Up @@ -5478,6 +5483,43 @@ def import_turtle(reader:typing.Any, schema_view:SchemaView, root_classes:typing
Import RDF/Turtle into a streaming iterator of LinkML instances.
"""

def lint_element_identity(schema_view:SchemaView) -> builtins.list[ValidationResult]:
r"""
Schema-level lint: warn where a multivalued inlined slot's element identity
is absent, ambiguous, or cannot address the list.

Five rules: (1) no identity declared at all; (2) the declared identity is
the element class's type designator, whose value describes the class rather
than the element; (3) several ``unique_keys`` entries to choose from across
the range class and its descendants, of which only the name-sorted first is
load-bearing; (4) those classes labelled in different ways, so one list
carries two label spaces; (5) two classes of one ``is_a`` hierarchy
declaring the same ``class_uri`` while the hierarchy designates its type.

Warnings only — the schema stays usable. Results are deterministic: sorted
by subject, deduplicated across class URIs, and an inherited slot is
reported once, at the class that introduces the problem. Rules 1-4 are
per-slot and their ``subject`` is ``[class_name, slot_name]``; rule 5 is
class-level and its ``subject`` is ``"shared_class_uri"`` followed by the
classes sharing the URI — the marker distinguishes it from a per-slot
subject, which a rendering that joins the segments could not otherwise do.
"""

def lint_instance_identity(instance:LinkMLInstance) -> builtins.list[ValidationResult]:
r"""
Data-level lint: warn where loaded data defeats a declared element identity.

Two rules: a list whose elements repeat a declared identity — key/identifier
or ``unique_keys`` value — reported as ``duplicate_element_identity``; and a
list addressed positionally *despite* a declared identity, because some
element leaves the slot that identity names empty, reported as
``ambiguous_element_identity``. Neither is visible in the schema: an
identity slot that is not ``required`` may be absent, and repeated or
missing values are alike valid data.

Warnings only. ``subject`` is the container's instance path.
"""

def load_json(source:typing.Any, sv:SchemaView, class_view:ClassView) -> tuple[typing.Optional[LinkMLInstance], builtins.list[ValidationResult]]: ...

def load_yaml(source:typing.Any, sv:SchemaView, class_view:ClassView) -> tuple[typing.Optional[LinkMLInstance], builtins.list[ValidationResult]]: ...
Expand Down
77 changes: 61 additions & 16 deletions src/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use linkml_runtime::diff::{
};
use linkml_runtime::turtle::{turtle_to_string, TurtleOptions};
use linkml_runtime::{
load_json_str, load_yaml_str, validate_issues, LinkMLInstance, LoadResult, NodeId,
ValidationProblemType, ValidationResult, ValidationSeverity, ValidationValue,
lint_element_identity, lint_instance_identity, load_json_str, load_yaml_str, validate_issues,
LinkMLInstance, LoadResult, NodeId, ValidationResult, ValidationSeverity, ValidationValue,
};
use linkml_schemaview::identifier::Identifier;
use linkml_schemaview::io;
Expand Down Expand Up @@ -744,6 +744,8 @@ pub fn runtime_module(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(load_json, m)?)?;
m.add_function(wrap_pyfunction!(py_diff, m)?)?;
m.add_function(wrap_pyfunction!(py_patch, m)?)?;
m.add_function(wrap_pyfunction!(py_lint_element_identity, m)?)?;
m.add_function(wrap_pyfunction!(py_lint_instance_identity, m)?)?;
m.add_function(wrap_pyfunction!(py_import_turtle, m)?)?;
m.add_function(wrap_pyfunction!(py_import_ntriples, m)?)?;
m.add_function(wrap_pyfunction!(py_export_turtle, m)?)?;
Expand Down Expand Up @@ -874,7 +876,7 @@ impl From<ValidationResult> for PyValidationResult {
impl PyValidationResult {
#[getter]
fn r#type(&self) -> String {
validation_problem_type_label(&self.inner.problem_type).to_string()
self.inner.problem_type.label().to_string()
}

#[getter]
Expand Down Expand Up @@ -915,7 +917,7 @@ impl PyValidationResult {
fn __repr__(&self) -> PyResult<String> {
Ok(format!(
"ValidationResult(type='{}', severity='{}', subject={:?}, detail={})",
validation_problem_type_label(&self.inner.problem_type),
self.inner.problem_type.label(),
severity_label(&self.inner.severity),
self.inner.subject,
self.inner.detail
Expand Down Expand Up @@ -1045,17 +1047,6 @@ fn validation_value_to_py(py: Python<'_>, value: &ValidationValue) -> PyResult<P
}
}

fn validation_problem_type_label(problem_type: &ValidationProblemType) -> &'static str {
match problem_type {
ValidationProblemType::UndeclaredSlot => "undeclared_slot",
ValidationProblemType::InapplicableSlot => "inapplicable_slot",
ValidationProblemType::MissingSlotValue => "missing_slot_value",
ValidationProblemType::SlotRangeViolation => "slot_range_violation",
ValidationProblemType::MaxCountViolation => "max_count_violation",
ValidationProblemType::ParsingError => "parsing_error",
}
}

fn severity_label(severity: &ValidationSeverity) -> &'static str {
match severity {
ValidationSeverity::Fatal => "fatal",
Expand Down Expand Up @@ -1223,7 +1214,12 @@ impl PyLinkMLInstance {
}
}

/// Navigate by a path of strings (map keys or list indices).
/// Navigate by a path of strings: slot names, mapping keys, and — for
/// lists — element identity labels (identifier/key or `unique_keys` value)
/// when the list carries unique labels, numeric indices otherwise. This is
/// the same addressing `diff` emits and `patch` applies, so delta paths are
/// navigable; a numeric segment aimed at a label-addressed list resolves to
/// nothing rather than to that position.
/// Returns a new LinkMLInstance if found, otherwise None.
#[pyo3(name = "navigate")]
fn py_navigate<'py>(
Expand Down Expand Up @@ -1569,6 +1565,55 @@ fn py_patch(
Py::new(py, result)
}

// ── Identity lints ──────────────────────────────────────────────────────────

/// Schema-level lint: warn where a multivalued inlined slot's element identity
/// is absent, ambiguous, or cannot address the list.
///
/// Five rules: (1) no identity declared at all; (2) the declared identity is
/// the element class's type designator, whose value describes the class rather
/// than the element; (3) several ``unique_keys`` entries to choose from across
/// the range class and its descendants, of which only the name-sorted first is
/// load-bearing; (4) those classes labelled in different ways, so one list
/// carries two label spaces; (5) two classes of one ``is_a`` hierarchy
/// declaring the same ``class_uri`` while the hierarchy designates its type.
///
/// Warnings only — the schema stays usable. Results are deterministic: sorted
/// by subject, deduplicated across class URIs, and an inherited slot is
/// reported once, at the class that introduces the problem. Rules 1-4 are
/// per-slot and their ``subject`` is ``[class_name, slot_name]``; rule 5 is
/// class-level and its ``subject`` is ``"shared_class_uri"`` followed by the
/// classes sharing the URI — the marker distinguishes it from a per-slot
/// subject, which a rendering that joins the segments could not otherwise do.
#[cfg_attr(feature = "stubgen", gen_stub_pyfunction)]
#[pyfunction(name = "lint_element_identity")]
fn py_lint_element_identity(
py: Python<'_>,
schema_view: &PySchemaView,
) -> PyResult<Vec<Py<PyValidationResult>>> {
validation_results_to_py(py, lint_element_identity(schema_view.as_rust()))
}

/// Data-level lint: warn where loaded data defeats a declared element identity.
///
/// Two rules: a list whose elements repeat a declared identity — key/identifier
/// or ``unique_keys`` value — reported as ``duplicate_element_identity``; and a
/// list addressed positionally *despite* a declared identity, because some
/// element leaves the slot that identity names empty, reported as
/// ``ambiguous_element_identity``. Neither is visible in the schema: an
/// identity slot that is not ``required`` may be absent, and repeated or
/// missing values are alike valid data.
///
/// Warnings only. ``subject`` is the container's instance path.
#[cfg_attr(feature = "stubgen", gen_stub_pyfunction)]
#[pyfunction(name = "lint_instance_identity")]
fn py_lint_instance_identity(
py: Python<'_>,
instance: &PyLinkMLInstance,
) -> PyResult<Vec<Py<PyValidationResult>>> {
validation_results_to_py(py, lint_instance_identity(&instance.value))
}

// ── RDF import/export ───────────────────────────────────────────────────────

/// Streaming iterator over harvested LinkML instances.
Expand Down
13 changes: 10 additions & 3 deletions src/python/tests/python_navigate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ value = load_no_errors(lr, lr.load_yaml, data_path, sv, cls)
assert 'objects' in value.keys()
assert value.navigate(['objects']) is not None

# Navigate list index then nested keys to scalar
name = value.navigate(['objects','2','has_medical_history','0','diagnosis','name'])
# Navigate a list by element identity label, then nested keys to a scalar.
# `objects` ranges on NamedThing, which declares an `id` identifier, so the
# list is addressed by that label, never by position. `has_medical_history`
# declares no identity and stays numeric.
name = value.navigate(['objects','P:002','has_medical_history','0','diagnosis','name'])
assert name is not None
assert name.as_python() == 'headache'

# Non-existent path -> None
assert value.navigate(['objects','1000']) is None
assert value.navigate(['objects','P:404']) is None

# A numeric segment aimed at a label-addressed list resolves to nothing
# rather than to that position: it never guesses an element.
assert value.navigate(['objects','2']) is None
"#
);
});
Expand Down
6 changes: 6 additions & 0 deletions src/runtime/src/blame/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ use std::fmt;
/// strategy: every node touched by the patch (added or updated according to the
/// [`PatchTrace`]) will have the supplied metadata cloned into the provided
/// `blame` map.
///
/// Deltas that did not apply are in [`PatchTrace::failed`] and touch nothing,
/// so they leave no blame entry — and, since [`patch`] never hard-errors on
/// one (spec addendum rule 4), an `Ok` here does not mean the whole batch
/// landed. Callers that treat a patch as atomic must check `trace.failed`
/// themselves; the blame map records what actually happened either way.
pub fn patch_with_blame<M: Clone>(
value: &LinkMLInstance,
deltas: &[Delta],
Expand Down
Loading
Loading