diff --git a/schema/mif.schema.json b/schema/mif.schema.json index f5a1f98..6bdb376 100644 --- a/schema/mif.schema.json +++ b/schema/mif.schema.json @@ -120,6 +120,13 @@ "maxLength": 500, "description": "Compressed content summary (Level 3, max 500 chars)" }, + "properties": { + "type": "object", + "additionalProperties": { + "type": ["string", "number", "boolean", "null"] + }, + "description": "First-class scalar properties: literal key/value pairs (e.g. from a knowledge-graph literal-object triple) that have no concept target and therefore cannot be a relationship. Values MUST be scalar (string, number, boolean, or null); nested objects/arrays are out of scope at Level 1." + }, "compressedAt": { "type": "string", "format": "date-time", diff --git a/scripts/mif_convert.py b/scripts/mif_convert.py index 5feef35..ecfdcba 100644 --- a/scripts/mif_convert.py +++ b/scripts/mif_convert.py @@ -46,11 +46,14 @@ FRONTMATTER_ORDER = [ "id", "type", + "memoryType", "created", "modified", "namespace", "title", "summary", + "properties", + "compressedAt", "tags", "aliases", "temporal", @@ -126,11 +129,14 @@ def md_to_jsonld(frontmatter: dict, body: str) -> dict: jsonld["conceptType"] = fm["type"] passthrough = [ + "memoryType", "created", "modified", "namespace", "title", "summary", + "properties", + "compressedAt", "tags", "aliases", "temporal", @@ -166,11 +172,14 @@ def jsonld_to_md(jsonld: dict) -> tuple[dict, str]: fm["type"] = jsonld["conceptType"] passthrough = [ + "memoryType", "created", "modified", "namespace", "title", "summary", + "properties", + "compressedAt", "tags", "aliases", "temporal", diff --git a/scripts/okf_validate.py b/scripts/okf_validate.py index 46e185a..f2df1f3 100644 --- a/scripts/okf_validate.py +++ b/scripts/okf_validate.py @@ -13,13 +13,17 @@ 4. broken bundle-relative links are tolerated -- reported as warnings, never failures (OKF tolerates broken links); 5. the ``markdown -> json-ld -> markdown`` projection round-trips losslessly - (delegated to ``mif_convert.roundtrip_file``). + (delegated to ``mif_convert.roundtrip_file``); +6. derivation edges are temporally consistent -- a ``derived-from`` / ``supersedes`` + / ``cites`` target must not be ``created`` after the concept that derives from + it. Reported as a warning by default (non-blocking); ``--strict-temporal`` + promotes it to a hard error so a known-clean corpus can enforce it in CI. Exit code 0 means every concept in every bundle conforms. Usage:: - python okf_validate.py [bundle-dir ...] + python okf_validate.py [bundle-dir ...] [--strict-temporal] python okf_validate.py # defaults to examples/ + profiles/*/examples/ """ @@ -27,12 +31,20 @@ import re import sys +from datetime import datetime, timezone from pathlib import Path +import yaml # PyYAML; parse_markdown's safe_load can raise yaml.YAMLError + import mif_convert # local module (same scripts/ directory) REPO_ROOT = Path(__file__).resolve().parent.parent +# Relationship types whose target is a *prior* concept the source is built on: +# the target must not be created AFTER the source. (Q-a: derived-from + supersedes +# + cites.) Stored kebab-normalized to match _kebab() output. +DERIVATION_TYPES = {"derived-from", "supersedes", "cites"} + # A body relationship line: "- [Text](/path/to/target.md)". REL_LINE_RE = re.compile(r"^-\s+([a-z0-9][a-z0-9-]*)\s+\[[^\]]+\]\(([^)]+)\)\s*$") # Any markdown link, for broken-link scanning. @@ -86,8 +98,87 @@ def _resolve(target: str, md_path: Path, bundle: Path) -> Path | None: return (md_path.parent / target).resolve() -def validate_bundle(bundle: Path) -> tuple[list[str], list[str], int]: - """Validate one bundle. Returns (errors, warnings, concept_count).""" +def _parse_created(value: object) -> datetime | None: + """Parse an ISO-8601 ``created`` value to an aware datetime (UTC if naive).""" + if not isinstance(value, str) or not value: + return None + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc) + + +def _is_date_only(value: object) -> bool: + """True when a ``created`` value carries no time-of-day (e.g. ``2024-06-01``).""" + return isinstance(value, str) and "T" not in value and " " not in value.strip() + + +def _temporal_findings( + frontmatter: dict, md_path: Path, bundle: Path, rel_name: object +) -> list[str]: + """Findings where a derivation target is created AFTER the deriving concept. + + A ``derived-from`` / ``supersedes`` / ``cites`` edge asserts the target predates + the source; a target with a later ``created`` is a logical impossibility the + schema/round-trip checks cannot see. Targets that don't resolve to an existing + in-bundle concept, or where either ``created`` is missing/unparseable, are + skipped (no false positives). + """ + findings: list[str] = [] + source_raw = frontmatter.get("created") + source_created = _parse_created(source_raw) + if source_created is None: + return findings + for rel_type, target in _frontmatter_relationships(frontmatter): + if rel_type not in DERIVATION_TYPES: + continue + resolved = _resolve(target, md_path, bundle) + if resolved is None or resolved.suffix != ".md" or not resolved.exists(): + continue + # Stay inside the bundle: a target that escapes (e.g. ``../other.md``) + # must not cause us to read an arbitrary file outside the bundle tree. + if not resolved.resolve().is_relative_to(bundle.resolve()): + continue + if resolved.resolve() == md_path.resolve(): + continue + try: + tgt_fm, _ = mif_convert.parse_markdown(resolved.read_text()) + except (ValueError, OSError, yaml.YAMLError): + continue + target_raw = tgt_fm.get("created") + target_created = _parse_created(target_raw) + if target_created is None: + continue + # If either side is date-only, a finer-grained other side would otherwise + # be compared against a manufactured midnight; downgrade both to date + # granularity so a same-day derivation is not a false positive. + if _is_date_only(source_raw) or _is_date_only(target_raw): + if target_created.date() > source_created.date(): + findings.append( + f"{rel_name}: temporal inconsistency -> '{rel_type}' target " + f"{target} is created {target_raw}, after concept " + f"created {source_raw}" + ) + continue + if target_created > source_created: + findings.append( + f"{rel_name}: temporal inconsistency -> '{rel_type}' target " + f"{target} is created {target_raw}, after concept " + f"created {source_raw}" + ) + return findings + + +def validate_bundle( + bundle: Path, strict_temporal: bool = False +) -> tuple[list[str], list[str], int]: + """Validate one bundle. Returns (errors, warnings, concept_count). + + ``strict_temporal`` promotes temporal-inconsistency findings from warnings to + hard errors (exit 1). Default is WARN so the check never blocks real-world use + until a corpus is known clean. + """ errors: list[str] = [] warnings: list[str] = [] count = 0 @@ -132,6 +223,10 @@ def validate_bundle(bundle: Path) -> tuple[list[str], list[str], int]: if rt_err: errors.append(f"{rel_name}: {rt_err}") + # (6) temporal consistency of derivation edges (WARN unless --strict-temporal). + temporal = _temporal_findings(frontmatter, md_path, bundle, rel_name) + (errors if strict_temporal else warnings).extend(temporal) + # (2) reserved-filename misuse is structural; rglob to catch any. for reserved in mif_convert.RESERVED_FILENAMES: for hit in bundle.rglob(reserved): @@ -154,7 +249,14 @@ def default_bundles() -> list[Path]: def main() -> None: args = sys.argv[1:] - bundles = [Path(a) for a in args] if args else default_bundles() + strict_temporal = False + positional: list[str] = [] + for arg in args: + if arg in ("--strict-temporal", "--temporal-strict"): + strict_temporal = True + else: + positional.append(arg) + bundles = [Path(a) for a in positional] if positional else default_bundles() if not bundles: print("No bundles found to validate.", file=sys.stderr) sys.exit(1) @@ -167,7 +269,7 @@ def main() -> None: if not bundle.exists(): all_errors.append(f"{bundle}: bundle directory not found") continue - errors, warnings, count = validate_bundle(bundle) + errors, warnings, count = validate_bundle(bundle, strict_temporal=strict_temporal) total += count all_errors.extend(errors) all_warnings.extend(warnings) diff --git a/scripts/test_temporal_and_properties.py b/scripts/test_temporal_and_properties.py new file mode 100644 index 0000000..db5bc7d --- /dev/null +++ b/scripts/test_temporal_and_properties.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Tests for the temporal-consistency check, the first-class scalar ``properties`` +construct, and the no-field-clobber guarantee across the full conversion chain. + +Run: ``python -m pytest scripts/test_temporal_and_properties.py -q`` from the repo root. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import jsonschema +import pytest + +# ``mif_convert``/``okf_validate`` live in this scripts/ dir. pytest's default +# (prepend) import mode already puts it on sys.path, but make it explicit so the +# imports also work under importmode=importlib or direct execution. +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import mif_convert # noqa: E402 +import okf_validate # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +TEMPORAL = ROOT / "test" / "temporal" +PROPERTIES = ROOT / "test" / "properties" + + +# --------------------------------------------------------------------------- # +# Temporal consistency (Q-a: derived-from + supersedes + cites; Q-b: WARN, # +# promotable to ERROR via --strict-temporal). # +# --------------------------------------------------------------------------- # +def test_temporal_violation_is_warning_by_default(): + errors, warnings, count = okf_validate.validate_bundle(TEMPORAL / "bad") + assert count == 2 + assert errors == [] # non-blocking by default — must not fail the run + temporal = [w for w in warnings if "temporal inconsistency" in w] + assert len(temporal) == 1 + assert "derived-from" in temporal[0] + + +def test_temporal_violation_promotes_to_error_in_strict_mode(): + errors, warnings, _ = okf_validate.validate_bundle( + TEMPORAL / "bad", strict_temporal=True + ) + temporal = [e for e in errors if "temporal inconsistency" in e] + assert len(temporal) == 1 + # No unrelated errors should appear in the bad bundle (scope the assertion). + assert [e for e in errors if "temporal inconsistency" not in e] == [] + assert [w for w in warnings if "temporal inconsistency" in w] == [] + + +def test_temporal_consistent_derivation_has_no_finding(): + errors, warnings, count = okf_validate.validate_bundle(TEMPORAL / "good") + assert count == 2 + assert errors == [] + assert [w for w in warnings if "temporal inconsistency" in w] == [] + + +def test_non_derivation_edge_is_not_temporally_checked(): + # A "relates-to" edge to a newer target is fine — only derivation edges order time. + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [{"type": "relates-to", "target": "/session.md"}], + } + findings = okf_validate._temporal_findings( + fm, TEMPORAL / "bad" / "observation.md", TEMPORAL / "bad", "x" + ) + assert findings == [] + + +def test_missing_created_skips_check_no_false_positive(): + fm = {"relationships": [{"type": "derived-from", "target": "/session.md"}]} + findings = okf_validate._temporal_findings( + fm, TEMPORAL / "bad" / "observation.md", TEMPORAL / "bad", "x" + ) + assert findings == [] + + +def _bundle_with_target(tmp_path, target_created): + """A bundle whose /target.md carries ``target_created`` (or no created if None).""" + fm = "" if target_created is None else f"created: {target_created}\n" + (tmp_path / "target.md").write_text( + f"---\nid: t\ntype: semantic\n{fm}---\nbody\n" + ) + return tmp_path + + +def _findings_for(tmp_path, rel_type, source_created, target_created): + bundle = _bundle_with_target(tmp_path, target_created) + fm = { + "created": source_created, + "relationships": [{"type": rel_type, "target": "/target.md"}], + } + return okf_validate._temporal_findings(fm, bundle / "src.md", bundle, "src") + + +@pytest.mark.parametrize("rel_type", ["derived-from", "supersedes", "cites"]) +def test_all_derivation_types_flag_future_target(tmp_path, rel_type): + # Every member of DERIVATION_TYPES must be temporally checked, not just derived-from. + findings = _findings_for( + tmp_path, rel_type, "2025-01-01T00:00:00Z", "2026-01-01T00:00:00Z" + ) + assert len(findings) == 1 + assert rel_type in findings[0] + + +def test_equal_timestamps_is_not_a_violation(tmp_path): + # Boundary: target created exactly at source time is "not after" -> no finding. + ts = "2026-01-01T00:00:00Z" + assert _findings_for(tmp_path, "derived-from", ts, ts) == [] + + +def test_date_only_same_day_is_not_a_false_positive(tmp_path): + # Date-only source vs same-day finer-grained target must not flag (no midnight bias). + findings = _findings_for( + tmp_path, "derived-from", "2024-06-01", "2024-06-01T09:00:00Z" + ) + assert findings == [] + + +def test_date_only_next_day_target_is_flagged(tmp_path): + findings = _findings_for( + tmp_path, "derived-from", "2024-06-01", "2024-06-02T00:00:00Z" + ) + assert len(findings) == 1 + + +def test_target_escaping_the_bundle_is_skipped(tmp_path): + # A target that resolves outside the bundle must NOT be read (no arbitrary + # file reads), so it produces no finding even with a future created. + bundle = tmp_path / "bundle" + bundle.mkdir() + (tmp_path / "outside.md").write_text( + "---\nid: o\ntype: semantic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [{"type": "derived-from", "target": "../outside.md"}], + } + findings = okf_validate._temporal_findings(fm, bundle / "src.md", bundle, "src") + assert findings == [] + + +def test_naive_source_datetime_does_not_raise(tmp_path): + # Naive (no tz) source is normalized to UTC; aware-vs-naive comparison must not raise. + findings = _findings_for( + tmp_path, "derived-from", "2025-01-15T10:30:00", "2026-01-01T00:00:00Z" + ) + assert len(findings) == 1 + + +def test_target_missing_created_skips_no_false_positive(tmp_path): + assert _findings_for(tmp_path, "derived-from", "2025-01-01T00:00:00Z", None) == [] + + +def test_malformed_yaml_target_is_skipped_not_crash(tmp_path): + # A derivation target with malformed YAML frontmatter must be skipped, never crash + # the run (the function's documented "unparseable -> skip" contract). + (tmp_path / "target.md").write_text("---\n bad: : : yaml\n :::\n---\nbody\n") + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [{"type": "derived-from", "target": "/target.md"}], + } + assert okf_validate._temporal_findings(fm, tmp_path / "src.md", tmp_path, "x") == [] + + +def test_multiple_future_targets_all_reported(tmp_path): + # Two future-target derivation edges -> two findings (guards an early-break regression). + (tmp_path / "a.md").write_text( + "---\nid: a\ntype: semantic\ncreated: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + (tmp_path / "b.md").write_text( + "---\nid: b\ntype: semantic\ncreated: 2026-06-01T00:00:00Z\n---\nbody\n" + ) + fm = { + "created": "2025-01-01T00:00:00Z", + "relationships": [ + {"type": "derived-from", "target": "/a.md"}, + {"type": "supersedes", "target": "/b.md"}, + ], + } + findings = okf_validate._temporal_findings(fm, tmp_path / "src.md", tmp_path, "x") + assert len(findings) == 2 + + +# --------------------------------------------------------------------------- # +# First-class scalar ``properties``. # +# --------------------------------------------------------------------------- # +def test_properties_roundtrips_losslessly(): + assert mif_convert.roundtrip_file(PROPERTIES / "concept.md") is None + + +def test_properties_survive_md_to_jsonld_and_back(): + fm, body = mif_convert.parse_markdown((PROPERTIES / "concept.md").read_text()) + jsonld = json.loads(json.dumps(mif_convert.md_to_jsonld(fm, body))) + assert jsonld["properties"] == { + "status": "active", + "priority": 1, + "archived": False, + "retired_on": None, + } + fm2, _ = mif_convert.jsonld_to_md(jsonld) + assert fm2["properties"] == fm["properties"] + + +def _properties_subschema() -> dict: + schema = json.loads((ROOT / "schema" / "mif.schema.json").read_text()) + return schema["properties"]["properties"] + + +def test_schema_accepts_scalar_properties(): + jsonschema.Draft202012Validator(_properties_subschema()).validate( + {"status": "active", "priority": 1, "archived": False, "retired_on": None} + ) + + +@pytest.mark.parametrize("bad", [{"nested": {"a": 1}}, {"list": [1, 2]}]) +def test_schema_rejects_non_scalar_properties(bad): + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(_properties_subschema()).validate(bad) + + +# --------------------------------------------------------------------------- # +# No MIF field is clobbered across MIF -> OKF(JSON-LD) -> MIF -> JSON* -> MIF. # +# --------------------------------------------------------------------------- # +FULL_FRONTMATTER = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "semantic", + "memoryType": "semantic", + "created": "2026-01-15T10:30:00Z", + "modified": "2026-02-01T08:00:00Z", + "namespace": "_semantic/observations", + "title": "Every-field concept", + "summary": "A concept exercising every top-level frontmatter field.", + # Scalar property values that YAML would re-type on reparse if left unquoted + # ("true"/"null"/date-/number-like strings) — pins the quoting guarantee. + "properties": { + "status": "active", + "priority": 1, + "archived": False, + "retired_on": None, + "flag_str": "true", + "void_str": "null", + "when_str": "2026-01-01", + "ratio_str": "1.5", + }, + "compressedAt": "2026-02-01T08:00:00Z", + "tags": ["a", "b"], + "aliases": ["alt-name"], + "temporal": {"validFrom": "2026-01-01T00:00:00Z", "validUntil": None}, + "provenance": {"sourceType": "agent_inferred", "confidence": 0.9}, + "embedding": {"model": "text-embed", "dimensions": 8}, + "relationships": [{"type": "derived-from", "target": "/other.md", "strength": 0.8}], + "citations": [{"title": "Ref", "url": "https://example.com/x"}], + "entities": [{"name": "Alice"}], + "ontology": {"id": "mif-base", "version": "1.0.0"}, + "entity": {"name": "Alice Chen", "entity_type": "person"}, + "blocks": {"b1": "block text"}, + "extensions": {"vendor": {"k": "v"}}, +} + +# JSON-LD keys produced by the projection itself (not 1:1 source frontmatter +# fields); excluded when asserting that every schema-defined source field survives. +_PROJECTION_ONLY_KEYS = { + "@context", + "@type", + "@id", + "conceptType", + "timestamp", + "description", + "content", +} + + +def test_no_field_clobbered_across_full_conversion_chain(): + body = "Body content for the every-field concept." + # MIF -> OKF (JSON-LD projection) + jsonld = mif_convert.md_to_jsonld(FULL_FRONTMATTER, body) + # Every MIF source field must be carried into the OKF projection (no drop/clobber). + for key, value in FULL_FRONTMATTER.items(): + if key == "id": + assert jsonld["@id"] == f"urn:mif:{value}" + elif key == "type": + assert jsonld["conceptType"] == value + else: + assert jsonld[key] == value, f"OKF projection clobbered {key!r}" + # MIF -> JSON* : actually serialize through JSON, as an on-disk projection would. + jsonld = json.loads(json.dumps(jsonld)) + # OKF -> MIF + fm1, body1 = mif_convert.jsonld_to_md(jsonld) + # MIF -> serialized markdown -> MIF (the on-disk markdown round) + md = mif_convert.serialize_markdown(fm1, body1) + fm2, body2 = mif_convert.parse_markdown(md) + # Final MIF frontmatter must equal the original, field for field. + assert fm2 == FULL_FRONTMATTER + for key in FULL_FRONTMATTER: + assert fm2[key] == FULL_FRONTMATTER[key], f"chain clobbered {key!r}" + assert body2.strip() == body.strip() + + +def test_every_schema_source_field_survives_round_trip(): + """Regression guard: every top-level schema property that is a source field + (not a projection-only key) must be present in FULL_FRONTMATTER and survive + the full chain. A new schema field that is forgotten in the converter's + passthrough lists fails here instead of silently dropping.""" + schema = json.loads((ROOT / "schema" / "mif.schema.json").read_text()) + source_fields = set(schema["properties"]) - _PROJECTION_ONLY_KEYS + # `type` is recovered from conceptType; every other source field (including + # memoryType, which is its own standalone passthrough key) must be present in + # FULL_FRONTMATTER and round-trip as itself. + covered = set(FULL_FRONTMATTER) + missing = source_fields - covered + assert not missing, f"schema source field(s) not exercised by the chain: {missing}" + jsonld = json.loads(json.dumps(mif_convert.md_to_jsonld(FULL_FRONTMATTER, "b"))) + fm2, _ = mif_convert.jsonld_to_md(jsonld) + for field in source_fields: + if field == "type": # surfaced as conceptType, recovered as type + assert fm2["type"] == FULL_FRONTMATTER["type"] + else: + assert fm2.get(field) == FULL_FRONTMATTER[field], f"dropped {field!r}" diff --git a/test/properties/concept.md b/test/properties/concept.md new file mode 100644 index 0000000..dd5757f --- /dev/null +++ b/test/properties/concept.md @@ -0,0 +1,16 @@ +--- +id: 1f2e3d4c-5b6a-5e7f-8a9b-0c1d2e3f4a5b +type: semantic +created: '2026-02-01T09:00:00Z' +namespace: _semantic/observations +title: Project Phoenix facts +properties: + status: active + priority: 1 + archived: false + retired_on: null +--- + +Literal-object knowledge-graph triples (predicate -> scalar value) map to +first-class `properties`, not to relationships, because there is no concept +`target` to point at. diff --git a/test/temporal/bad/observation.md b/test/temporal/bad/observation.md new file mode 100644 index 0000000..14750a3 --- /dev/null +++ b/test/temporal/bad/observation.md @@ -0,0 +1,17 @@ +--- +id: 897fbf98-a6cd-5e0d-b719-18095c15ab5c +type: semantic +created: '2025-03-04T12:00:00Z' +namespace: _semantic/observations +title: Alice Chen works on Project Phoenix +relationships: +- type: derived-from + target: /session.md +--- + +Reproduces PR #65: this observation is `derived-from` a session that is +`created` ten months in its future, which is logically impossible. + +## Relationships + +- derived-from [Phoenix Rate-Spike Debug Session](/session.md) diff --git a/test/temporal/bad/session.md b/test/temporal/bad/session.md new file mode 100644 index 0000000..0a4bda1 --- /dev/null +++ b/test/temporal/bad/session.md @@ -0,0 +1,9 @@ +--- +id: 0c523a47-28b3-559f-94ce-692cf88c2d9f +type: episodic +created: '2026-01-15T10:30:00Z' +namespace: _episodic/sessions +title: Phoenix Rate-Spike Debug Session +--- + +A debug session created after the observation that claims to derive from it. diff --git a/test/temporal/good/observation.md b/test/temporal/good/observation.md new file mode 100644 index 0000000..01b4bca --- /dev/null +++ b/test/temporal/good/observation.md @@ -0,0 +1,17 @@ +--- +id: 897fbf98-a6cd-5e0d-b719-18095c15ab5c +type: semantic +created: '2026-02-01T12:00:00Z' +namespace: _semantic/observations +title: Alice Chen works on Project Phoenix +relationships: +- type: derived-from + target: /session.md +--- + +Same shape as the bad fixture, but `created` is after its source session, so +the derivation is temporally consistent and must produce no finding. + +## Relationships + +- derived-from [Phoenix Rate-Spike Debug Session](/session.md) diff --git a/test/temporal/good/session.md b/test/temporal/good/session.md new file mode 100644 index 0000000..bf706e4 --- /dev/null +++ b/test/temporal/good/session.md @@ -0,0 +1,9 @@ +--- +id: 0c523a47-28b3-559f-94ce-692cf88c2d9f +type: episodic +created: '2026-01-15T10:30:00Z' +namespace: _episodic/sessions +title: Phoenix Rate-Spike Debug Session +--- + +The source session, created before the observation that derives from it.