Skip to content

Instantly share code, notes, and snippets.

@wware
Last active June 3, 2026 17:51
Show Gist options
  • Select an option

  • Save wware/9dbc623c6a73d6133ad41519f99d9d7f to your computer and use it in GitHub Desktop.

Select an option

Save wware/9dbc623c6a73d6133ad41519f99d9d7f to your computer and use it in GitHub Desktop.
Defining the typed graph in mathematical terms, and a schema for Sherlock Holmes stories

The Typed Graph: a formal definition

This defines the formal model precisely, establishes vocabulary, states hard rules, and lists explicit non-goals. When in doubt, check against this file before generating code, prose, or schema definitions.

The notation itself isn't the point — the benefits come from what the process of formalizing forces, and those benefits survive translation into plain prose.

It settles ambiguity permanently. Natural language descriptions of data structures always leave wiggle room. "Edges have types" could mean a dozen things. Writing $E \subseteq V \times T_E \times V$ closes off all of them at once. Even if readers never see that line, the author has made decisions that make every subsequent explanation consistent.

It separates schema from instance. The $T_V / T_E$ vs. $V / E$ split is the single most important conceptual distinction in the book. A formal definition makes it impossible to conflate the two. Readers who never see a Greek letter still benefit because every diagram, every code example, every analogy will cleanly respect that boundary.

It gives you a checklist. The 6-tuple (plus $\Phi$) is a completeness check. If you can't place something in one of those slots, either it doesn't belong in the model or the model is missing a slot. That discipline propagates into the code, the explanations, and the worked examples.

It anchors the vocabulary. Once you've defined $\text{Tr}(p)$ formally, "trait" has a precise meaning for the rest of the book. Readers who are shaky on the math still absorb the word through repeated use in concrete contexts.


The other two base vectors of reliable reasoning

Neither of these are essential to the typed graph as a mathematical object. Both are implemented using the (also non-essential) $\Phi$ element which assigns a property schema to each entity type and predicate.

Provenance and traceability

In high-stakes domains like medicine, law, aircraft design, etc., it should be possible to track any assertion in the graph back to the source document from which it was extracted. This makes chains of reasoning traceable and transparent. It allows domain experts to confirm or refute any conclusion reached while reasoning on the graph.

Canonical IDs from authoritative ontologies

Any high-stakes domain will have meticulously curated ontologies that are widely accepted as authoritative descriptions of the objects and concepts relevant to the field. A canonical ID is an unambiguous reference to a term from such an ontology, frequently framed as a URI. Using these as entity IDs in a graph ensures that reasoning can reliably cross boundaries between diverse papers, journals, and publications, without loss of reasoning validity.


Formal Definition

A typed graph G is a 6-tuple $(T_V,\ T_E,\ V,\ E,\ \tau_V,\ \tau_E)$ where:

Schema layer (types) — fixed at graph-design time

  • $T_V$ — finite set of entity types
  • $T_E$ — finite set of predicate types
  • For each $p \in T_E$:
    • $\text{dom}(p) \subseteq T_V$ — permitted subject entity types
    • $\text{ran}(p) \subseteq T_V$ — permitted object entity types
    • $\text{Tr}(p) \subseteq \text{Trait}$ — finite set of semantic traits

A field-bearing typed graph additionally equips each entity type and predicate type with a field schema $\Phi: T_V \cup T_E \to \text{FieldSchema}$, and requires each instance to carry fields conforming to $\Phi$ of its type.

$\Phi$ is a computational convenience and a record schema enforcement mechanism. It handles validated field shapes, required fields, and value type constraints, but is conceptually separable from the graph topology. Think of a FieldSchema as a Pydantic model.

Instance layer — populated at ingestion or reasoning time

  • $V$ — set of entity instances
  • $E \subseteq V \times T_E \times V$ — set of directed, typed edge instances (triples)
  • $\tau_V: V \to T_V$ — type assignment for entities
  • $\tau_E: E \to T_E$ — induced by the middle element of each edge triple

Validity constraints

  1. Domain/range: $\forall (v_1,\ p,\ v_2) \in E:\ \tau_V(v_1) \in \text{dom}(p)\ \land\ \tau_V(v_2) \in \text{ran}(p)$
  2. Schema conformance: each $v \in V$ carries fields satisfying $\Phi(\tau_V(v))$; each $e \in E$ carries fields satisfying $\Phi(p_e)$

Trait vocabulary

$$\text{Trait} ::= \text{Symmetric} \mid \text{Transitive} \mid \text{Functional} \mid \text{InverseFunctional} \mid \text{Inverse}(p') \mid \text{Rule}(\phi \Rightarrow \psi)$$

Traits are realized as Python mixin classes inherited alongside BaseEdge. The unparameterized traits (Symmetric, Transitive, Functional, InverseFunctional) are plain marker classes. Inverse is a generic parameterized by the partner predicate type. Rule has no clean type-level expression and is documented in prose on the edge class.

Examples:

  • LocatedIn(BaseEdge, Transitive) — carries $\text{Tr}(p) = {\text{Transitive}}$
  • MarriedTo(BaseEdge, Symmetric) — carries $\text{Tr}(p) = {\text{Symmetric}}$
  • HasTrueIdentity(BaseEdge, Functional) — carries $\text{Tr}(p) = {\text{Functional}}$
  • Contains(BaseEdge, Inverse[ContainedBy]) — carries $\text{Tr}(p) = {\text{Inverse}(\texttt{ContainedBy})}$

Vocabulary

Use these terms consistently throughout the book. Do not treat them as synonyms.

Term Definition
Entity type A member of $T_V$. Realized as a Python class inheriting from EntityInstance. Defines a class of entities: the fields they carry and their permitted roles in edges. Example: Person, Location, Moment.
Predicate type A member of $T_E$. Realized as a Python class inheriting from BaseEdge. Defines a class of edges: their field schema, domain, range, and traits. Example: LocatedIn, KnowsAt, Possesses.
Entity instance A member of $V$. A concrete node in the graph — an instance of an EntityInstance subclass with an id and data fields. Example: a specific Person instance for Sherlock Holmes.
Edge instance A member of $E$. A concrete directed triple $(v_1, p, v_2)$ — an instance of a BaseEdge subclass. The thing that carries metadata such as provenance and epistemic status.
Field schema The Pydantic model declaration of named fields and their types for a given entity type or predicate type. Defined by $\Phi$.
Domain $\text{dom}(p)$ — the set of entity types permitted in the subject role for predicate $p$. Declared as the Python type annotation of the subject field on the edge class.
Range $\text{ran}(p)$ — the set of entity types permitted in the object role for predicate $p$. Declared as the Python type annotation of the object_ field on the edge class.
Trait A declarative semantic property of a predicate type. Member of $\text{Tr}(p)$. Belongs to the schema, not to any edge instance. Realized as a Python mixin class inherited by the edge class: class LocatedIn(BaseEdge, Transitive).
Schema The tuple $(T_V,\ T_E,\ \Phi)$ together with domain, range, and trait declarations. Fixed at graph-design time.
Instance graph The tuple $(V,\ E,\ \tau_V,\ \tau_E)$. Populated at ingestion or reasoning time.
Reification Promoting an edge instance to a named entity so that other predicates can take it as an argument. This model eliminates the need for reification to annotate edges — provenance and epistemic fields travel as typed fields on the edge instance itself. Reification remains necessary and appropriate when a proposition must appear in the subject or object role of a higher-order predicate (e.g. an epistemic predicate whose object is a specific asserted fact). That use is intentional, not an anti-pattern.

Terms to avoid or use carefully

  • Relationship — use this to mean edge instance, never a predicate type.
  • Node — informal synonym for entity instance. Acceptable in casual prose, not in definitions.
  • Property — overloaded; could mean a field on an instance or a trait on a predicate type. Be explicit.
  • Axiom — not used in this model. Use trait instead.

Hard Rules

These rules follow from the formal definition and must not be violated in code examples, schema designs, or explanatory prose.

R1. Traits belong to predicate types, never to edge instances. A predicate either has Transitive or it does not. That is part of what the predicate means. An individual edge instance cannot be transitive or non-transitive — that distinction belongs to its type. In Python, traits are declared by inheriting the trait mixin class alongside BaseEdge; they are visible on the class, not on any instance.

R2. Metadata fields belong to edge instances, never to predicate types. Provenance, confidence, timestamps, epistemic status — all of these are facts about a particular assertion. They live on the edge instance. The predicate type defines which fields are required (via $\Phi$), but carries no values itself.

R3. Edges are directed triples, not annotated pairs. An edge instance is $(v_1, p, v_2)$ where $p \in T_E$. The predicate type is part of the edge's identity, not an annotation on an untyped link. This is what makes domain/range validation possible at construction time.

R4. Domain and range are sets of entity types, not entity instances. Formally, $\text{dom}(p) \subseteq T_V$, not $\subseteq V$. You constrain which kinds of things may appear as subject or object, not which specific things.

R5. Schema is fixed; instances are populated. Nothing discovered during ingestion changes $T_V$, $T_E$, $\Phi$, domain, range, or traits. If a new entity type seems necessary mid-ingestion, that is a schema design problem, not an ingestion problem.

R6. Domain and range constraints are enforced by the Python type system. Entity types are Python classes, not ID prefixes. Each edge class declares its subject and object_ fields with concrete EntityInstance subclass annotations. Mypy enforces these constraints statically; Pydantic enforces them at construction time. No bespoke ID parsing, prefix conventions, or model_validator machinery is needed or permitted for domain/range enforcement. When a predicate permits multiple subject or object types, declare a Union (e.g. subject: Person | Organization). The field name object_ is used throughout to avoid shadowing the Python builtin object.

R7. Pydantic models for edge instances are frozen. Use model_config = ConfigDict(frozen=True). Edge instances are facts; they must not be mutated after construction.


Python Enforcement Pattern

Entity types are EntityInstance subclasses. Predicate types are BaseEdge subclasses with trait mixins inherited alongside. Domain and range constraints require no custom validation logic — they are expressed as type annotations and enforced by mypy and Pydantic.

from __future__ import annotations
from typing import ClassVar, Generic, Literal, TypeVar, get_args, get_origin
from pydantic import BaseModel, Field, ConfigDict


# ------------------------------------------------------------------
# Entity base class and concrete types
# ------------------------------------------------------------------

class EntityInstance(BaseModel):
    model_config = ConfigDict(frozen=True)
    id: str

    def __repr__(self) -> str:
        return f"{type(self).__name__}({self.id!r})"


class Person(EntityInstance): ...
class Location(EntityInstance): ...
class Moment(EntityInstance): ...
class Object(EntityInstance): ...
class Document(EntityInstance): ...


# ------------------------------------------------------------------
# Trait mixin classes
#
# Unparameterized traits are plain marker classes. Inverse is a generic
# parameterized by the partner predicate type. Rule has no clean
# type-level expression and is documented in prose on the edge class.
#
# Trait must not inherit from BaseModel; it is a plain mixin so that
# Pydantic's ModelMetaclass remains in control of edge class construction.
# ------------------------------------------------------------------

class Trait:
    """Marker base for all semantic traits."""
    ...

class Transitive(Trait): ...
class Symmetric(Trait): ...
class Functional(Trait): ...
class InverseFunctional(Trait): ...

P = TypeVar('P', bound='BaseEdge')

class Inverse(Trait, Generic[P]):
    """This predicate is the inverse of P."""
    ...


def get_inverse(cls: type[BaseEdge]) -> type[BaseEdge] | None:
    """Return the declared inverse predicate type, if any."""
    for base in getattr(cls, '__orig_bases__', []):
        if get_origin(base) is Inverse:
            return get_args(base)[0]
    return None


# ------------------------------------------------------------------
# Edge base class and concrete predicates
# ------------------------------------------------------------------

class BaseEdge(BaseModel):
    model_config = ConfigDict(frozen=True)


class LocatedIn(BaseEdge, Transitive):
    """A Location is situated within another Location."""
    subject: Location
    object_: Location


class ContainedBy(BaseEdge, Inverse['Contains']):
    """A Location is contained by another Location."""
    subject: Location
    object_: Location


class Contains(BaseEdge, Inverse[ContainedBy]):
    """A Location contains another Location."""
    subject: Location
    object_: Location


class PossessesAt(BaseEdge):
    """A Person possesses an Object as of a given Moment."""
    subject: Person
    object_: Object
    at_moment: Moment
    confidence: float = Field(ge=0.0, le=1.0, default=1.0)
    known_to: frozenset[str] = frozenset()  # person IDs
    epistemic_status: Literal[
        "ground_truth", "believed", "false_belief", "inferred"
    ] = "ground_truth"


class MentionedIn(BaseEdge):
    """A Person or Location is mentioned in a Document."""
    subject: Person | Location
    object_: Document

Passing a Location where a Person is required is a mypy error and a Pydantic ValidationError at construction time. No runtime prefix parsing is involved. Fields that reference other entities — such as at_moment — carry typed EntityInstance subclass values, not strings.

Traits are introspectable at runtime: issubclass(LocatedIn, Transitive) is True; get_inverse(Contains) returns ContainedBy. The ContainedBy forward reference in Inverse['Contains'] resolves once both classes are defined.


Non-Goals

Not RDF / OWL. In RDF, predicates are URIs and are themselves nodes; the graph is a flat set of triples with no first-class edge objects. OWL adds description logic semantics and open-world assumption. This model is a closed-world property graph with typed, field-bearing edge instances. The distinction matters especially for annotating edges: RDF requires reification or named graphs; this model puts metadata directly on the edge instance.

Not Neo4j's informal property graph. Neo4j allows arbitrary key-value properties on edges without schema enforcement. This model requires a declared field schema ($\Phi$) and enforced domain/range constraints. The structure is similar; the discipline is different.

Not an entity-relationship diagram. ER diagrams are a database design tool. This is a runtime knowledge representation with provenance, epistemic scope, and trait-based inference semantics.

Not a general ontology language. This model does not support open-world reasoning, class hierarchies, disjointness axioms, or the full OWL trait vocabulary. Traits are a small, fixed set of declarative properties. If a use case seems to require full description logic, that is scope creep.

Not a stringly-typed system. Entity types are Python classes, not ID prefixes. Domain and range enforcement is the job of the Python type system and Pydantic, not of string parsing. Any pattern that encodes type information inside an identifier string and then parses that string to enforce constraints is a violation of R6.


Current Domain: Holmes Corpus

The worked example uses the Sherlock Holmes canon as domain.

  • Ontology authority: Baker Street Wiki (https://bakerstreet.fandom.com)
  • Schema construction method: inductive — built by annotating stories, not pre-designed
  • Primary stories: "A Scandal in Bohemia", "The Speckled Band"
  • Provisional entity types: Person, Location, Object, Document, Moment, Event, Persona, Plan
  • Epistemic fields on edge instances: known_to, at_moment, epistemic_status

The Holmes schema must satisfy all hard rules above. If a design decision for the Holmes domain seems to require violating a rule, that is a signal to revisit the decision, not to bend the rule.

"""
Holmes Schema — Python realization of the typed graph schema for the
Sherlock Holmes canonical corpus (Conan Doyle, 60 stories).
Schema version: 0.6.0-draft
Authoritative ontology: Baker Street Wiki (https://bakerstreet.fandom.com)
Identity conventions
--------------------
Entities with an external authority (Person, Location, Object, Document):
id IS the Baker Street Wiki URI.
Corpus-local entities (Event, Moment, Statement, Plan, Persona):
id is a synthetic identifier, stable within the corpus but not grounded
in any external authority.
"""
import sys
from typing import ClassVar, ForwardRef, Generic, Literal, TypeVar, get_args, get_origin
from pydantic import BaseModel, Field, ConfigDict
# ── Trait mixin classes ────────────────────────────────────────────────────────
#
# Traits are declarative semantic properties of predicate types (R1).
# They belong to the class, not to any edge instance.
# Trait must NOT inherit from BaseModel so that Pydantic's ModelMetaclass
# stays in control of edge class construction.
class Trait:
"""Marker base for all semantic traits."""
class Transitive(Trait): ...
class Symmetric(Trait): ...
class Functional(Trait): ...
class InverseFunctional(Trait): ...
P = TypeVar('P', bound='BaseEdge')
class Inverse(Trait, Generic[P]):
"""This predicate is the inverse of P."""
def get_inverse(cls: type['BaseEdge']) -> type['BaseEdge'] | None:
"""Return the declared inverse predicate type, if any."""
module = sys.modules[cls.__module__].__dict__
for base in getattr(cls, '__orig_bases__', []):
if get_origin(base) is Inverse:
args = get_args(base)
if args:
arg = args[0]
if isinstance(arg, str): # e.g. Inverse['HasTrueIdentity']
return module.get(arg)
if isinstance(arg, ForwardRef): # e.g. ForwardRef('HasTrueIdentity')
return module.get(arg.__forward_arg__)
return arg # already a resolved class
return None
# ── Base classes ───────────────────────────────────────────────────────────────
class EntityInstance(BaseModel):
"""Base class for all entity types (members of T_V)."""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
id: str
def __repr__(self) -> str:
return f"{type(self).__name__}({self.id!r})"
class BaseEdge(BaseModel):
"""Base class for all predicate types (members of T_E)."""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
# ── Entity types (T_V) ─────────────────────────────────────────────────────────
class Person(EntityInstance):
"""A human character with an independent existence in the fictional world.
id IS the Baker Street Wiki URI."""
display_name: str
class Persona(EntityInstance):
"""An assumed identity adopted as a disguise. Not treated as a subtype of
Person — predicates accepting both declare an explicit union (Person | Persona).
id is a corpus-local synthetic identifier."""
display_name: str
class Location(EntityInstance):
"""A physical place referenced in the stories.
id IS the Baker Street Wiki URI."""
display_name: str
class Object(EntityInstance):
"""A significant physical object.
id IS the Baker Street Wiki URI."""
display_name: str
class Document(EntityInstance):
"""A load-bearing text or image artifact: letter, photograph, telegram,
newspaper. Distinct from Object because documents carry content and
authenticity properties physical objects do not.
id IS the Baker Street Wiki URI."""
display_name: str
story_id: str
document_type: Literal["letter", "photograph", "telegram", "newspaper", "other"]
class Event(EntityInstance):
"""A discrete occurrence within a story: an action, observation, revelation.
id is corpus-local."""
story_id: str
description: str
class Moment(EntityInstance):
"""A named point on a timeline.
narrator absent → objective story timeline (when something happened).
narrator present → that Person's epistemic timeline (when they learned it).
One type carries both axes the corpus demands. id is corpus-local."""
story_id: str
label: str
narrator: Person | None = None
class Statement(EntityInstance):
"""A proposition — (subject, predicate_type, object) — considered
independently of its truth value.
Allows meta-predicates (KnewAt, Contradicts) to take propositions as
arguments without asserting them. predicate_type holds the edge class
itself (a member of T_E), not a string name — the type system enforces
T_E membership.
truth_status lifecycle:
hypothetical → asserted_true | asserted_false → disputed | retracted
id is corpus-local."""
subject: EntityInstance
predicate_type: type[BaseEdge] # the class object; mypy checks T_E membership
object_: EntityInstance
truth_status: Literal[
"asserted_true", "asserted_false", "hypothetical", "disputed", "retracted"
] = "hypothetical"
class Plan(EntityInstance):
"""A goal-directed sequence of actions attributed to an agent.
id is corpus-local."""
provisional: ClassVar[bool] = True
story_id: str
description: str
goal: str | None = None
# ── Field group mixins (Φ components) ─────────────────────────────────────────
#
# Field groups are reusable FieldSchema components realizing Φ from the formal
# definition. Each edge class that uses a group inherits the corresponding mixin.
# Named "field groups" (not "trait groups") to avoid collision with the formal
# trait vocabulary (Symmetric, Transitive, Functional, Inverse, Rule).
class ProvenanceMixin(BaseModel):
"""Source-tracing fields. Required on every evidential edge.
extraction_confidence: pipeline confidence that the text asserts this.
asserting_narrator: who in the story reported it; absent if the source
is the omniscient narrator or is unattributable."""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
story_id: str
paragraph_index: int
asserting_narrator: Person | None = None
extraction_method: str
extraction_confidence: float = Field(ge=0.0, le=1.0)
class EpistemicMixin(BaseModel):
"""In-world certainty. Distinct from extraction_confidence (pipeline) and
from truth_status (graph-level commitment).
narrator_confidence: how certain the in-story narrator is at narration time."""
model_config = ConfigDict(frozen=True)
narrator_confidence: float | None = Field(default=None, ge=0.0, le=1.0)
class AssertionMixin(BaseModel):
"""Graph-level commitment to the proposition a direct edge asserts.
Applies to edges making direct factual claims (Knows, DisguisedAs,
HasTrueIdentity) that can be disputed or retracted. Does NOT apply to
structural edges (LocatedIn, OccurredAt, Involves) or meta-level edges
(KnewAt) where truth is handled differently.
Default is asserted_true — an edge present in the graph is assumed
asserted unless marked otherwise."""
model_config = ConfigDict(frozen=True)
truth_status: Literal[
"asserted_true", "asserted_false", "hypothetical", "disputed", "retracted"
] = "asserted_true"
# ── Edge types (T_E) ───────────────────────────────────────────────────────────
#
# MRO convention: BaseEdge first, then field group mixins, then Trait/Inverse
# markers last. This keeps all BaseModel subclasses ahead of the generic
# Inverse[...] alias and avoids Pydantic's GenericBeforeBaseModelWarning.
class AssociatedWith(BaseEdge, ProvenanceMixin):
"""Person is habitually connected to a location."""
subject: Person
object_: Location
class Knows(BaseEdge, ProvenanceMixin, EpistemicMixin, AssertionMixin, Symmetric):
"""Person has an acquaintance or professional relationship with another.
Deliberately coarse; split into sub-predicates only if the corpus rewards it.
Symmetric: if Holmes knows Watson, Watson knows Holmes."""
subject: Person
object_: Person
class LocatedIn(BaseEdge, ProvenanceMixin, Transitive):
"""A location is situated within another location. Transitive.
Asserted edges carry source provenance; inferred transitive edges carry
inference provenance instead."""
subject: Location
object_: Location
class Involves(BaseEdge, ProvenanceMixin):
"""Event involves an entity as a participant. Accepts Person or Persona —
if Holmes attended in disguise, the Persona is the participant visible to
other characters; the real Person is recoverable via HasTrueIdentity."""
subject: Event
object_: Person | Persona
class OccurredAt(BaseEdge, ProvenanceMixin):
"""Event is anchored to its objective point in time. The Moment's narrator
field must be absent (objective timeline). Distinct from KnewAt."""
subject: Event
object_: Moment
class KnewAt(BaseEdge, ProvenanceMixin, EpistemicMixin):
"""Person came to know a proposition (Statement) at a given Moment.
subject = knower; object_ = proposition; moment = when they learned it.
No AssertionMixin: whether Watson actually knew the proposition is
answered by Statement.truth_status and edge provenance, not by a further
truth_status on this edge."""
subject: Person
object_: Statement
moment: Moment
class DisguisedAs(BaseEdge, ProvenanceMixin, EpistemicMixin, AssertionMixin,
Inverse['HasTrueIdentity']):
"""Real Person adopted a Persona. A person may hold many disguises.
Inverse of HasTrueIdentity."""
subject: Person
object_: Persona
class HasTrueIdentity(BaseEdge, ProvenanceMixin, EpistemicMixin, AssertionMixin,
Functional, Inverse[DisguisedAs]):
"""A Persona conceals exactly one real Person. Functional: a persona
cannot conceal more than one person. Inverse of DisguisedAs."""
subject: Persona
object_: Person
class Contradicts(BaseEdge, ProvenanceMixin, Symmetric):
"""One Statement directly contradicts another. Symmetric. Used to surface
conflicts between belief states or between a character's belief and ground
truth."""
provisional: ClassVar[bool] = True
subject: Statement
object_: Statement
class Executes(BaseEdge, ProvenanceMixin):
"""Person is the agent responsible for carrying out a Plan."""
provisional: ClassVar[bool] = True
subject: Person
object_: Plan
# ── Forward reference resolution ───────────────────────────────────────────────
#
# Pydantic v2 resolves string annotations lazily. Call model_rebuild() after
# all classes are defined so that cross-references (e.g. KnewAt → Statement,
# Contradicts → Statement) are fully wired.
for _cls in [
Person, Persona, Location, Object, Document,
Event, Moment, Statement, Plan,
AssociatedWith, Knows, LocatedIn, Involves, OccurredAt,
KnewAt, DisguisedAs, HasTrueIdentity, Contradicts, Executes,
]:
_cls.model_rebuild()
#!/usr/bin/env python3
"""
Poor man's literate programming preprocessor.
Input file list can be a mix of Markdown and Python files. These will be
processed in order and emitted as raw Markdown.
When processing a Python file:
- Top-level string literals are rendered as Markdown (pass-through verbatim).
- All other top-level statements are collected into fenced code blocks
(```python ... ```) with leading and trailing blank lines stripped.
Example usage:
$ python3 proc.py --title 'Example usage' \
preface.md example.py foo.py bar.py baz.py > example.md
"""
import ast
import textwrap
from pathlib import Path
def process_md(source: str, path: str | None = None) -> str:
"""Returns a Markdown document for one Python source file."""
tree = ast.parse(source)
source_lines = source.splitlines(keepends=True)
parts: list[str] = []
if path is not None:
parts.append(f"# `{path}`")
# Track line span of the current run of non-prose nodes.
code_start: int | None = None
code_end: int | None = None
def flush_code() -> None:
nonlocal code_start, code_end
if code_start is None:
return
block = "".join(source_lines[code_start:code_end]).strip("\n")
if block:
parts.append("```python\n" + block + "\n```")
code_start = code_end = None
for node in tree.body:
match node:
case ast.Expr(value=ast.Constant(value=str() as s)):
flush_code()
parts.append(textwrap.dedent(s).strip())
case _:
if code_start is None:
code_start = node.lineno - 1 # type: ignore[attr-defined]
code_end = node.end_lineno # type: ignore[attr-defined]
flush_code()
return "\n\n".join(parts)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Poor man's literate programming preprocessor.",
epilog="Example: %(prog)s --title 'My Docs' intro.md src/foo.py src/bar.py",
)
parser.add_argument(
"-t",
"--title",
metavar="TITLE",
default=None,
help="document title",
)
parser.add_argument(
"files", nargs="+", metavar="FILE", help="Markdown or Python source files to process"
)
args = parser.parse_args()
chunks: list[str] = []
if args.title:
chunks.append(f"# {args.title}")
for path in args.files:
p = Path(path)
if p.suffix == ".md":
chunks.append(p.read_text().strip())
else:
chunks.append(process_md(p.read_text(), path))
print("\n\n---\n\n".join(chunks) + "\n") # noqa: T201

The Holmes Schema

Holmes Schema — Python realization of the typed graph schema for the Sherlock Holmes canonical corpus (Conan Doyle, 60 stories).

Schema version: 0.6.0-draft Authoritative ontology: Baker Street Wiki (https://bakerstreet.fandom.com)

Identity conventions

Entities with an external authority (Person, Location, Object, Document): id IS the Baker Street Wiki URI.

Corpus-local entities (Event, Moment, Statement, Plan, Persona): id is a synthetic identifier, stable within the corpus but not grounded in any external authority.

Why Holmes

The Holmes canon is the book's primary worked example not because it is simple, but because it is hard in the right ways. It exercises all three reliability vectors simultaneously:

  • Provenance: Watson is an unreliable narrator. He asserts things that turn out to be false; Holmes occasionally misdirects. An edge without an asserting narrator is ambiguous in a way that matters.
  • Canonical identity: Characters appear under assumed names and disguises. The graph must track who someone is independently of what they are presenting as.
  • Typed structure: Retroactive reinterpretation — Holmes revealing that what we thought we knew was wrong — stresses the closed-world assumption and the distinction between asserted and hypothetical facts.

Holmes is also deliberately low-stakes: if the graph is wrong, nobody dies. That makes it a clean teaching corpus. The chapter that raises the stakes (medical literature, legal evidence, intelligence analysis) can show how the same machinery becomes load-bearing when errors have consequences.

Design principles

Design for this story with general intentions. Name things generically where it costs nothing; do not contort the schema to accommodate cases that do not yet exist. A second story stress-tests the schema cheaply.

The schema is fixed; instances are populated. Nothing discovered during ingestion changes entity types, predicate types, field schemas, domain, range, or traits (R5 of the formal definition).

No stringly-typed enforcement. Entity types are Python classes. Domain and range constraints are type annotations enforced by mypy and Pydantic, not by parsing ID prefixes or string conventions (R6).

import sys
from typing import ClassVar, ForwardRef, Generic, Literal, TypeVar, get_args, get_origin
from pydantic import BaseModel, Field, ConfigDict

Traits

Traits are declarative semantic properties of predicate types. They belong to the class, never to any edge instance (R1 of the formal definition).

The unparameterized traits (Symmetric, Transitive, Functional, InverseFunctional) are plain marker classes. Inverse is a generic parameterized by the partner predicate type. Rule has no clean type-level expression and is documented in prose on the edge class.

Trait must not inherit from BaseModel — it is a plain mixin so that Pydantic's ModelMetaclass remains in control of edge class construction.

Traits are introspectable at runtime: issubclass(LocatedIn, Transitive) is True; get_inverse(DisguisedAs) returns HasTrueIdentity.

class Trait:
    """Marker base for all semantic traits."""


class Transitive(Trait): ...
class Symmetric(Trait): ...
class Functional(Trait): ...
class InverseFunctional(Trait): ...

P = TypeVar('P', bound='BaseEdge')


class Inverse(Trait, Generic[P]):
    """This predicate is the inverse of P."""


def get_inverse(cls: type['BaseEdge']) -> type['BaseEdge'] | None:
    """Return the declared inverse predicate type, if any."""
    module = sys.modules[cls.__module__].__dict__
    for base in getattr(cls, '__orig_bases__', []):
        if get_origin(base) is Inverse:
            args = get_args(base)
            if args:
                arg = args[0]
                if isinstance(arg, str):
                    return module.get(arg)
                if isinstance(arg, ForwardRef):
                    return module.get(arg.__forward_arg__)
                return arg
    return None


class EntityInstance(BaseModel):
    """Base class for all entity types (members of T_V)."""
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
    id: str

    def __repr__(self) -> str:
        return f"{type(self).__name__}({self.id!r})"


class BaseEdge(BaseModel):
    """Base class for all predicate types (members of T_E)."""
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)

Entity types

Person and Persona

A Persona is an assumed identity with no authoritative external referent. Rather than making Persona a subtype of Person — which would require extending the formal definition's 6-tuple with a partial order on T_V — Persona is a flat, independent type. Predicates that accept both declare an explicit union (Person | Persona) in their domain/range. In Python this is a Union type annotation. The flat-type choice is ontologically honest: a Persona is not genuinely a kind of Person.

Document vs. Object

A photograph is an object. Treating Document as a flat sibling of Object rather than a subtype is a pragmatic deferral, not an ontological claim. The Persona/Person case is different — a Persona is genuinely not a kind of Person. Document/Object is not the same case; a photograph IS a physical object, and a schema that says otherwise is lying to keep the formalism simple.

The reason it is deferred rather than implemented: subtyping earns its keep when predicates that accept Object in their domain or range also need to accept Document. Currently no predicate in this schema takes Object at all. The moment Possesses(Person → Object) is added — and it will be — the right move is class Document(Object). Pydantic's standard validation accepts subclass instances wherever the parent is declared, so domain/range written as Object automatically accepts Document without union syntax.

Event vs. Moment

An Event happens (the fire alarm, the escape, the meeting at the church). A Moment is a temporal position — a named point on a timeline. Events are the subjects of OccurredAt and Involves. Moments anchor events to timelines and serve as the knower-scoped positions for epistemic tracking.

A Moment without a narrator is on the objective story timeline (when something happened). A Moment with a narrator is on that person's epistemic timeline (when they came to know something). One type carries both axes.

Statement

The central new entity. A Statement is a proposition — (subject, predicate_type, object) — considered independently of its truth value. It exists to solve the propositional attitude problem: KnewAt(Watson, moment, what) requires "what" to be a referable object.

Two distinct things are often both called "reification":

  1. Attaching metadata to a fact (provenance, confidence). Property graph edge fields solve this cleanly — no Statement entity needed.
  2. Referring to a fact as the argument of another fact (Watson knew that X; S1 contradicts S2). This requires the proposition to have an identity. It is irreducible.

Statement handles case 2. truth_status tracks the graph's current commitment: hypotheticalasserted_true or asserted_falsedisputed or retracted.

Statement.subject and Statement.object_ are typed as EntityInstance (the base class) rather than any specific union. The proposition's predicate type already constrains what the participants can be via domain/range. Constraining them again at the Statement level would require updating Statement whenever a new predicate is added.

predicate_type holds the edge class object itself (type[BaseEdge]), not a string name. The type system enforces T_E membership statically; arbitrary_types_allowed=True on EntityInstance permits Pydantic to accept a class object as a field value.

Plan

Provisional. Holmes has a plan; Irene has a counter-plan. Plans have goals, steps, and an agent. Capturing the strategic layer distinguishes a graph that encodes facts from one that encodes reasoning.

class Person(EntityInstance):
    """A human character with an independent existence in the fictional world.
    id IS the Baker Street Wiki URI."""
    display_name: str


class Persona(EntityInstance):
    """An assumed identity adopted as a disguise. Not treated as a subtype of
    Person — predicates accepting both declare an explicit union (Person | Persona).
    id is a corpus-local synthetic identifier."""
    display_name: str


class Location(EntityInstance):
    """A physical place referenced in the stories.
    id IS the Baker Street Wiki URI."""
    display_name: str


class Object(EntityInstance):
    """A significant physical object.
    id IS the Baker Street Wiki URI."""
    display_name: str


class Document(EntityInstance):
    """A load-bearing text or image artifact: letter, photograph, telegram,
    newspaper. Distinct from Object because documents carry content and
    authenticity properties physical objects do not.
    id IS the Baker Street Wiki URI."""
    display_name: str
    story_id: str
    document_type: Literal["letter", "photograph", "telegram", "newspaper", "other"]


class Event(EntityInstance):
    """A discrete occurrence within a story: an action, observation, revelation.
    id is corpus-local."""
    story_id: str
    description: str


class Moment(EntityInstance):
    """A named point on a timeline.

    narrator absent  → objective story timeline (when something happened).
    narrator present → that Person's epistemic timeline (when they learned it).

    One type carries both axes the corpus demands. id is corpus-local."""
    story_id: str
    label: str
    narrator: Person | None = None


class Statement(EntityInstance):
    """A proposition — (subject, predicate_type, object) — considered
    independently of its truth value.

    Allows meta-predicates (KnewAt, Contradicts) to take propositions as
    arguments without asserting them. predicate_type holds the edge class
    itself (a member of T_E), not a string name — the type system enforces
    T_E membership.

    truth_status lifecycle:
        hypothetical → asserted_true | asserted_false → disputed | retracted

    id is corpus-local."""
    subject: EntityInstance
    predicate_type: type[BaseEdge]
    object_: EntityInstance
    truth_status: Literal[
        "asserted_true", "asserted_false", "hypothetical", "disputed", "retracted"
    ] = "hypothetical"


class Plan(EntityInstance):
    """A goal-directed sequence of actions attributed to an agent.
    id is corpus-local."""
    provisional: ClassVar[bool] = True
    story_id: str
    description: str
    goal: str | None = None

Field groups (Φ components)

Field groups are reusable FieldSchema components realizing Φ from the formal definition. Each edge class that uses a group inherits the corresponding mixin. Named "field groups" (not "trait groups") to avoid collision with the formal trait vocabulary (Symmetric, Transitive, Functional, Inverse, Rule).

ProvenanceMixin — source-tracing fields. asserting_narrator is a typed Person reference, not a raw URI. Required on every evidential edge.

EpistemicMixin — in-world certainty. narrator_confidence is how certain the in-story narrator is at the time of narration. Distinct from extraction_confidence (pipeline confidence that the text says X).

AssertionMixin — graph-level commitment to the proposition a direct edge asserts. Applies to edges making direct factual claims (Knows, DisguisedAs, HasTrueIdentity) that can be disputed or retracted. Does not apply to structural edges (LocatedIn, OccurredAt, Involves) or to meta-level edges (KnewAt) where truth_status is handled differently. Default is asserted_true — an edge present in the graph is assumed asserted unless marked otherwise.

MRO convention for edge classes: BaseEdge first, then field group mixins, then trait/inverse markers last. This keeps all BaseModel subclasses ahead of the generic Inverse[...] alias and avoids Pydantic's GenericBeforeBaseModelWarning.

class ProvenanceMixin(BaseModel):
    """Source-tracing fields. Required on every evidential edge.

    extraction_confidence: pipeline confidence that the text asserts this.
    asserting_narrator: who in the story reported it; absent if the source
        is the omniscient narrator or is unattributable."""
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
    story_id: str
    paragraph_index: int
    asserting_narrator: Person | None = None
    extraction_method: str
    extraction_confidence: float = Field(ge=0.0, le=1.0)


class EpistemicMixin(BaseModel):
    """In-world certainty. Distinct from extraction_confidence (pipeline) and
    from truth_status (graph-level commitment).

    narrator_confidence: how certain the in-story narrator is at narration time."""
    model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
    narrator_confidence: float | None = Field(default=None, ge=0.0, le=1.0)


class AssertionMixin(BaseModel):
    """Graph-level commitment to the proposition a direct edge asserts.

    Applies to edges making direct factual claims (Knows, DisguisedAs,
    HasTrueIdentity) that can be disputed or retracted. Does NOT apply to
    structural edges (LocatedIn, OccurredAt, Involves) or meta-level edges
    (KnewAt) where truth is handled differently.

    Default is asserted_true — an edge present in the graph is assumed
    asserted unless marked otherwise."""
    model_config = ConfigDict(frozen=True)
    truth_status: Literal[
        "asserted_true", "asserted_false", "hypothetical", "disputed", "retracted"
    ] = "asserted_true"

Edge types

The three-layer epistemic model

The schema must keep three layers distinct:

  • Ground truth: what actually happened in the story world.
  • Agent belief states: what Watson, Holmes, Lestrade, etc. knew or believed at each moment. Represented by KnewAt edges and Statement.truth_status.
  • Narrative revelation: when the reader / LLM is told something.

The "just before the reveal" requirement — that the graph should allow an LLM to solve the mystery immediately before Holmes discloses the answer — depends on these layers being separable. A graph that conflates ground truth with belief state cannot support that reasoning.

KnewAt

KnewAt replaces two earlier designs: a known_to: frozenset[str] field on edges (stringly-typed, models a relationship as an attribute) and a known_at: Event → Moment predicate (hardcoded the knower into the Moment rather than making it the subject). The current design makes the knower the subject, the proposition the object, and the moment a typed entity field on the edge. KnewAt does not carry AssertionMixin — the question of whether Watson actually knew something is answered by Statement.truth_status and the provenance on the edge.

OccurredAt

Kept separate from KnewAt. When something happened and when someone learned of it are different questions. The Moment for OccurredAt has no narrator (objective timeline). The Moment for KnewAt carries the knower as its narrator field.

DisguisedAs / HasTrueIdentity

These form an Inverse pair. HasTrueIdentity is also Functional — a persona conceals exactly one real person. This pair is a clean demonstration of the Inverse trait.

Contradicts and Executes

Both are provisional (provisional: ClassVar[bool] = True). Contradicts is Symmetric (Statement ↔ Statement) and is the mechanism for surfacing conflicts between Watson's beliefs and ground truth. Executes captures the strategic layer: Holmes has a plan; Irene has a counter-plan.

class AssociatedWith(BaseEdge, ProvenanceMixin):
    """Person is habitually connected to a location."""
    subject: Person
    object_: Location


class Knows(BaseEdge, ProvenanceMixin, EpistemicMixin, AssertionMixin, Symmetric):
    """Person has an acquaintance or professional relationship with another.
    Deliberately coarse; split into sub-predicates only if the corpus rewards it.
    Symmetric: if Holmes knows Watson, Watson knows Holmes."""
    subject: Person
    object_: Person


class LocatedIn(BaseEdge, ProvenanceMixin, Transitive):
    """A location is situated within another location. Transitive.
    Asserted edges carry source provenance; inferred transitive edges carry
    inference provenance instead."""
    subject: Location
    object_: Location


class Involves(BaseEdge, ProvenanceMixin):
    """Event involves an entity as a participant. Accepts Person or Persona —
    if Holmes attended in disguise, the Persona is the participant visible to
    other characters; the real Person is recoverable via HasTrueIdentity."""
    subject: Event
    object_: Person | Persona


class OccurredAt(BaseEdge, ProvenanceMixin):
    """Event is anchored to its objective point in time. The Moment's narrator
    field must be absent (objective timeline). Distinct from KnewAt."""
    subject: Event
    object_: Moment


class KnewAt(BaseEdge, ProvenanceMixin, EpistemicMixin):
    """Person came to know a proposition (Statement) at a given Moment.

    subject = knower; object_ = proposition; moment = when they learned it.

    No AssertionMixin: whether Watson actually knew the proposition is
    answered by Statement.truth_status and edge provenance, not by a further
    truth_status on this edge."""
    subject: Person
    object_: Statement
    moment: Moment


class DisguisedAs(BaseEdge, ProvenanceMixin, EpistemicMixin, AssertionMixin,
                  Inverse['HasTrueIdentity']):
    """Real Person adopted a Persona. A person may hold many disguises.
    Inverse of HasTrueIdentity."""
    subject: Person
    object_: Persona


class HasTrueIdentity(BaseEdge, ProvenanceMixin, EpistemicMixin, AssertionMixin,
                      Functional, Inverse[DisguisedAs]):
    """A Persona conceals exactly one real Person. Functional: a persona
    cannot conceal more than one person. Inverse of DisguisedAs."""
    subject: Persona
    object_: Person


class Contradicts(BaseEdge, ProvenanceMixin, Symmetric):
    """One Statement directly contradicts another. Symmetric. Used to surface
    conflicts between belief states or between a character's belief and ground
    truth."""
    provisional: ClassVar[bool] = True
    subject: Statement
    object_: Statement


class Executes(BaseEdge, ProvenanceMixin):
    """Person is the agent responsible for carrying out a Plan."""
    provisional: ClassVar[bool] = True
    subject: Person
    object_: Plan

Explicit rejections

Means, Motive, Opportunity as entity types. These are relational properties, not substances. They belong as predicates or edge fields (motive_for, enables), not as nodes.

known_to: frozenset[str] on edges. Stringly-typed (person IDs as strings), and models a relationship as an attribute. Replaced by explicit KnewAt edges.

canonical_id as a separate field. For entities with an external authority, id IS the canonical URI. Two identity fields on the same entity was a design error. For corpus-local entities (Event, Moment, Statement, Plan, Persona), id is a synthetic identifier.

Subtype hierarchy in the formal definition. For Persona vs. Person, explicit union types cost almost nothing and the flat-type choice is also ontologically accurate. Document vs. Object is a different case — a photograph IS an object — and subtyping there is deferred rather than rejected. See the entity types section above for when and how to implement it.

Inference nodes as first-class entities. Construction cost is high and the schema becomes brittle if inference patterns are named and encoded. The middle path — annotated edge fields carrying inferred_by and inference_confidence — delivers most of the benefit at a fraction of the cost. Full inference nodes are a second-iteration feature.

Open items

  • Second story stress test. The schema was designed for A Scandal in Bohemia. A second story with different structure (alibi problem, locked room, multiple suspects) would reveal gaps and force generalization.
  • Vehicle entity type. Not needed for Bohemia; add if a subsequent story requires it.
  • Plan refinement. The Plan type is provisional. Steps, preconditions, and success conditions may want their own structure.
  • Inference nodes. Deferred; revisit after the extraction pipeline is running and the cost of manual inference modeling is better understood.
for _cls in [
    Person, Persona, Location, Object, Document,
    Event, Moment, Statement, Plan,
    AssociatedWith, Knows, LocatedIn, Involves, OccurredAt,
    KnewAt, DisguisedAs, HasTrueIdentity, Contradicts, Executes,
]:
    _cls.model_rebuild()
"""
# A Scandal in Bohemia — instance graph
This file populates the first instance graph from the Holmes corpus.
Source: *The Adventures of Sherlock Holmes*, Project Gutenberg edition
(https://www.gutenberg.org/files/1661/1661-h/1661-h.htm).
Paragraph indices below refer to the double-newline-separated paragraphs
of the story text, zero-indexed from the title. `extraction_method` is
`"manual"` throughout; `extraction_confidence` is `1.0` for unambiguous
assertions and lower where Watson hedges.
This graph exercises all major schema features:
- `DisguisedAs` / `HasTrueIdentity` inverse pair (King + Holmes both
appear in disguise)
- `LocatedIn` transitive chain (Briony Lodge → St. John's Wood → London)
- `KnewAt` + `Statement` demonstrating the three-layer epistemic model
- `Possesses` (added to the schema when the photograph forced the issue)
- `Symmetric` (`Knows`), `Functional` (`HasTrueIdentity`)
"""
import importlib
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
_s = importlib.import_module("2_HolmesSchema")
AssociatedWith = _s.AssociatedWith
DisguisedAs = _s.DisguisedAs
Document = _s.Document
Event = _s.Event
Executes = _s.Executes
HasTrueIdentity = _s.HasTrueIdentity
Involves = _s.Involves
KnewAt = _s.KnewAt
Knows = _s.Knows
Location = _s.Location
LocatedIn = _s.LocatedIn
Moment = _s.Moment
Object = _s.Object
OccurredAt = _s.OccurredAt
Person = _s.Person
Persona = _s.Persona
Plan = _s.Plan
Possesses = _s.Possesses
Statement = _s.Statement
"""
## Provenance shorthand
Every edge in this file was extracted manually from the Project Gutenberg
text of *A Scandal in Bohemia*. Rather than repeating the boilerplate on
every edge constructor, `_p(para)` returns the shared fields; the caller
adds `asserting_narrator` when Watson is explicitly the asserting voice,
or omits it when the omniscient narrator speaks.
"""
STORY = "scandal_in_bohemia"
def _p(para: int, narrator: "Person | None" = None) -> dict:
return dict(
story_id=STORY,
paragraph_index=para,
extraction_method="manual",
extraction_confidence=1.0,
asserting_narrator=narrator,
)
"""
## Persons
Five people with speaking roles or decisive agency. Baker Street Wiki URIs
serve as canonical IDs for all five — they are externally authoritative
entities, not corpus-local constructs.
"""
holmes = Person(
id="https://bakerstreet.fandom.com/wiki/Sherlock_Holmes",
display_name="Sherlock Holmes",
)
watson = Person(
id="https://bakerstreet.fandom.com/wiki/John_H._Watson",
display_name="Dr. John H. Watson",
)
irene_adler = Person(
id="https://bakerstreet.fandom.com/wiki/Irene_Adler",
display_name="Irene Adler",
)
king_of_bohemia = Person(
id="https://bakerstreet.fandom.com/wiki/Wilhelm_Gottsreich_Sigismond_von_Ormstein",
display_name="Wilhelm Gottsreich Sigismond von Ormstein",
)
godfrey_norton = Person(
id="https://bakerstreet.fandom.com/wiki/Godfrey_Norton",
display_name="Godfrey Norton",
)
"""
## Personas
Two disguises appear in this story, each exercising the `DisguisedAs` /
`HasTrueIdentity` inverse pair.
**Count Von Kramm** is the alias the King uses when visiting Baker Street.
He announces it himself (para 46), then drops the mask at para 57 when
Holmes makes clear he already knows. Watson is present and witnesses both
the persona and the revelation.
**The Nonconformist Clergyman** is Holmes's cover when he infiltrates
Briony Lodge on the evening of March 21 (para 173). Irene later writes
that he took her in completely (para 246).
Both are corpus-local — no Baker Street Wiki authority covers fictional
aliases as first-class articles.
"""
count_von_kramm = Persona(
id="sib:persona:count_von_kramm",
display_name="Count Von Kramm",
)
nonconformist_clergyman = Persona(
id="sib:persona:nonconformist_clergyman",
display_name="Nonconformist Clergyman",
)
"""
## Locations
The geographic chain London → St. John's Wood → Briony Lodge is the one
transitive `LocatedIn` chain this story exercises. Baker Street 221B is
separately situated in London but not through an intermediate district in
this telling.
"""
london = Location(
id="https://bakerstreet.fandom.com/wiki/London",
display_name="London",
)
st_johns_wood = Location(
id="https://bakerstreet.fandom.com/wiki/St._John%27s_Wood",
display_name="St. John's Wood",
)
baker_street_221b = Location(
id="https://bakerstreet.fandom.com/wiki/221B_Baker_Street",
display_name="221B Baker Street",
)
briony_lodge = Location(
id="https://bakerstreet.fandom.com/wiki/Briony_Lodge",
display_name="Briony Lodge, Serpentine Avenue",
)
church_of_st_monica = Location(
id="https://bakerstreet.fandom.com/wiki/Church_of_St._Monica",
display_name="Church of St. Monica, Edgware Road",
)
"""
## Objects and Documents
**The photograph** is the story's MacGuffin — a cabinet-size photograph
of Irene Adler and the King together (para 78, 119). Its recovery is the
stated goal of the King's commission; its retention is Irene's insurance.
It is the reason `Possesses` had to be added to the schema.
**The advance note** is the unsigned, undated letter delivered to Holmes
before the King's visit (para 22–23). Written on Bohemian paper with a
German sentence construction — the clues Holmes reads before the King
arrives.
**Irene's farewell letter** is left at Briony Lodge for Holmes to find the
morning of March 22 (para 246–247). Signed "Irene Norton, née Adler." It
resolves the plot: she has fled with the photograph but will not use it
now that she is married and happy.
"""
irene_photograph = Object(
id="https://bakerstreet.fandom.com/wiki/Irene_Adler%27s_photograph",
display_name="Cabinet photograph of Irene Adler and the King",
)
advance_note = Document(
id="sib:doc:advance_note",
display_name="Advance note from the King's agent",
story_id=STORY,
document_type="letter",
)
farewell_letter = Document(
id="sib:doc:farewell_letter",
display_name="Farewell letter from Irene Norton to Sherlock Holmes",
story_id=STORY,
document_type="letter",
)
"""
## Events
Six discrete occurrences anchor the story's causal structure.
| ID | Description | Para |
|---|---|---|
| `evt_kings_visit` | King visits Baker Street as Count Von Kramm | 43 |
| `evt_holmes_surveillance` | Holmes watches Briony Lodge as a groom | 129 |
| `evt_norton_visits_irene` | Godfrey Norton arrives at Briony Lodge | 135 |
| `evt_wedding` | Irene and Norton marry at St. Monica's | 140–144 |
| `evt_fake_fire_alarm` | Holmes triggers false alarm at Briony Lodge | 196 |
| `evt_discovery_of_flight` | Holmes/Watson/King find Irene has fled | 238–245 |
Holmes witnesses the wedding (para 144) as an impromptu witness, already
in his groom's disguise. He is pulled into the ceremony by Norton and
completes it before recognising the strategic significance.
"""
evt_kings_visit = Event(
id="sib:event:kings_visit",
story_id=STORY,
description="The King of Bohemia visits Holmes at Baker Street, initially as Count Von Kramm.",
)
evt_holmes_surveillance = Event(
id="sib:event:holmes_surveillance",
story_id=STORY,
description="Holmes loiters near Briony Lodge disguised as a groom, observing Irene and Norton.",
)
evt_norton_visits_irene = Event(
id="sib:event:norton_visits_irene",
story_id=STORY,
description="Godfrey Norton arrives at Briony Lodge by hansom cab and meets with Irene.",
)
evt_wedding = Event(
id="sib:event:wedding_at_st_monica",
story_id=STORY,
description="Irene Adler and Godfrey Norton marry at the Church of St. Monica.",
)
evt_fake_fire_alarm = Event(
id="sib:event:fake_fire_alarm",
story_id=STORY,
description="Holmes, disguised as a clergyman, stages a fake fire alarm at Briony Lodge to locate the photograph.",
)
evt_discovery_of_flight = Event(
id="sib:event:discovery_of_flight",
story_id=STORY,
description="Holmes, Watson, and the King arrive at Briony Lodge to find Irene has fled to the Continent.",
)
"""
## Moments
Moments without a `narrator` sit on the **objective story timeline** —
when something happened in the world of the story. Moments with a narrator
sit on that person's **epistemic timeline** — when they came to know
something.
The story's objective timeline spans three days in March 1888 (para 4
establishes "the twentieth of March, 1888"). Four objective moments and two
epistemic moments are needed to model the story's key revelations.
"""
moment_kings_visit = Moment(
id="sib:moment:kings_visit_evening",
story_id=STORY,
label="Evening of 20 March 1888 — King visits Baker Street",
)
moment_wedding = Moment(
id="sib:moment:wedding_morning",
story_id=STORY,
label="Morning of 21 March 1888 — Wedding at St. Monica's",
)
moment_fake_fire = Moment(
id="sib:moment:fake_fire_evening",
story_id=STORY,
label="Evening of 21 March 1888 — fake fire alarm at Briony Lodge",
)
moment_discovery = Moment(
id="sib:moment:discovery_morning",
story_id=STORY,
label="Morning of 22 March 1888 — discovery that Irene has fled",
)
# Epistemic moments: narrator field marks these as personal knowledge timelines.
moment_watson_sees_king_unmasked = Moment(
id="sib:moment:watson_sees_king_unmasked",
story_id=STORY,
label="Watson witnesses the King remove his mask",
narrator=watson,
)
moment_watson_learns_irene_married = Moment(
id="sib:moment:watson_learns_irene_married",
story_id=STORY,
label="Holmes tells Watson that Irene married Norton (in the King's brougham)",
narrator=watson,
)
"""
## Statements
A `Statement` is a proposition considered independently of its truth value.
Two are needed here to demonstrate the epistemic model:
**`stmt_king_is_count_von_kramm`** — the King adopted the Count Von Kramm
persona. Watson comes to know this when the King tears off his mask (para
57). The proposition was true all along; Watson's epistemic timeline simply
lags the objective one.
**`stmt_irene_possesses_photograph`** — Irene holds the photograph. This
is `asserted_true` based on the King's account (para 78–88) and remains
so until the farewell letter confirms she has taken it with her to the
Continent. The schema does not currently model the *retraction* of this
assertion — that is a future annotation task.
"""
stmt_king_is_count_von_kramm = Statement(
id="sib:stmt:king_is_count_von_kramm",
subject=king_of_bohemia,
predicate_type=DisguisedAs,
object_=count_von_kramm,
truth_status="asserted_true",
)
stmt_irene_possesses_photograph = Statement(
id="sib:stmt:irene_possesses_photograph",
subject=irene_adler,
predicate_type=Possesses,
object_=irene_photograph,
truth_status="asserted_true",
)
"""
## Structural edges
### Geographic containment (`LocatedIn`, Transitive)
The transitive chain `briony_lodge → st_johns_wood → london` means that
any reasoner following `LocatedIn` edges transitively can derive
`briony_lodge LocatedIn london` without an explicit edge. Baker Street 221B
is directly in London without an intermediate district in this story.
"""
e_briony_in_stjohnswood = LocatedIn(
subject=briony_lodge, object_=st_johns_wood,
**_p(118, watson),
)
e_stjohnswood_in_london = LocatedIn(
subject=st_johns_wood, object_=london,
**_p(118, watson),
)
e_bakerstreet_in_london = LocatedIn(
subject=baker_street_221b, object_=london,
**_p(4, watson),
)
e_stmonica_in_london = LocatedIn(
subject=church_of_st_monica, object_=london,
**_p(138, watson),
)
"""
### Habitual associations (`AssociatedWith`)
"""
e_holmes_at_bakerstreet = AssociatedWith(
subject=holmes, object_=baker_street_221b,
**_p(3, watson),
)
e_irene_at_briony = AssociatedWith(
subject=irene_adler, object_=briony_lodge,
**_p(118, watson),
)
"""
## Social edges (`Knows`, Symmetric)
`Knows` is symmetric — one edge suffices for a mutual acquaintance.
The paragraph is Watson's opening scene at Baker Street (para 5), which
establishes their long friendship. Watson also *knows of* Irene Adler
through Holmes's index entry (para 63), but that is second-hand knowledge
and carries lower confidence.
"""
e_holmes_knows_watson = Knows(
subject=holmes, object_=watson,
**_p(5, watson),
)
e_watson_knows_of_irene = Knows(
subject=watson, object_=irene_adler,
**{**_p(63, watson), "extraction_confidence": 0.7},
)
"""
## Identity edges (`DisguisedAs` / `HasTrueIdentity`)
Each disguise requires a forward edge (`DisguisedAs`) and its inverse
(`HasTrueIdentity`). `HasTrueIdentity` is also `Functional`: a persona
can conceal at most one real person.
The King drops his mask at para 57; Holmes's clergyman persona is
described at para 173. Both are asserted by Watson as eyewitness narrator.
"""
e_king_as_count = DisguisedAs(
subject=king_of_bohemia, object_=count_von_kramm,
**_p(46, watson),
)
e_count_is_king = HasTrueIdentity(
subject=count_von_kramm, object_=king_of_bohemia,
**_p(57, watson),
)
e_holmes_as_clergyman = DisguisedAs(
subject=holmes, object_=nonconformist_clergyman,
**_p(173, watson),
)
e_clergyman_is_holmes = HasTrueIdentity(
subject=nonconformist_clergyman, object_=holmes,
**_p(173, watson),
)
"""
## Possession (`Possesses`)
The photograph's provenance is the King's direct testimony (para 78: "We
were both in the photograph"). Irene's possession is his claim, not
Watson's direct observation, so `asserting_narrator` is None here —
Watson is reporting what the King said, and the King is the asserting
voice. The `extraction_confidence` is high because the King has no motive
to lie about whether she has it.
"""
e_irene_possesses_photo = Possesses(
subject=irene_adler, object_=irene_photograph,
**{**_p(78), "extraction_confidence": 0.95},
)
"""
## Event structure (`OccurredAt`, `Involves`)
`OccurredAt` anchors each event to its objective moment. `Involves` names
each participant; one edge per participant per event.
Note that the King *arrives* as Count Von Kramm, so the `Involves` edge
for `evt_kings_visit` uses the `count_von_kramm` Persona as the visible
participant — that is who Watson perceives in the room. The real person is
recoverable via `HasTrueIdentity`. After para 57 the mask is off, but by
then the event is already in progress.
Similarly, Holmes attends the wedding (para 144) and the fake fire (para
196) as the clergyman persona — not as himself.
"""
e_kings_visit_time = OccurredAt(
subject=evt_kings_visit, object_=moment_kings_visit, **_p(43, watson),
)
e_wedding_time = OccurredAt(
subject=evt_wedding, object_=moment_wedding, **_p(140, watson),
)
e_fake_fire_time = OccurredAt(
subject=evt_fake_fire_alarm, object_=moment_fake_fire, **_p(196, watson),
)
e_discovery_time = OccurredAt(
subject=evt_discovery_of_flight, object_=moment_discovery, **_p(238, watson),
)
# evt_kings_visit participants
e_visit_involves_count = Involves(subject=evt_kings_visit, object_=count_von_kramm, **_p(43, watson))
e_visit_involves_holmes = Involves(subject=evt_kings_visit, object_=holmes, **_p(43, watson))
e_visit_involves_watson = Involves(subject=evt_kings_visit, object_=watson, **_p(43, watson))
# evt_wedding participants
e_wedding_involves_irene = Involves(subject=evt_wedding, object_=irene_adler, **_p(140, watson))
e_wedding_involves_norton = Involves(subject=evt_wedding, object_=godfrey_norton, **_p(140, watson))
e_wedding_involves_holmes = Involves(subject=evt_wedding, object_=nonconformist_clergyman, **_p(144, watson))
# evt_fake_fire_alarm participants
e_fire_involves_holmes = Involves(subject=evt_fake_fire_alarm, object_=nonconformist_clergyman, **_p(196, watson))
e_fire_involves_irene = Involves(subject=evt_fake_fire_alarm, object_=irene_adler, **_p(196, watson))
# evt_discovery_of_flight participants
e_disc_involves_holmes = Involves(subject=evt_discovery_of_flight, object_=holmes, **_p(238, watson))
e_disc_involves_watson = Involves(subject=evt_discovery_of_flight, object_=watson, **_p(238, watson))
e_disc_involves_king = Involves(subject=evt_discovery_of_flight, object_=king_of_bohemia, **_p(238, watson))
"""
## Epistemic edges (`KnewAt`)
These edges demonstrate the three-layer epistemic model: they record not
*what happened* but *when a specific person came to know a proposition*.
**Watson learns the King's true identity** at `moment_watson_sees_king_unmasked`
(para 57). The King was always the King; the objective fact is
`stmt_king_is_count_von_kramm`. Watson's epistemic timeline simply
catches up to objective reality at that moment.
**Watson learns Irene possesses the photograph** at `moment_kings_visit`
(para 78) — Watson is in the room when the King reveals this. The
proposition was already true; Watson now knows it.
The graph contains no `KnewAt` edge for Holmes on either of these,
because Holmes already knew both before para 57 (he tells the King "I was
aware of it"). Modelling Holmes's prior knowledge is a future annotation
task — it requires reconstructing Holmes's inference chain, which belongs
to the inference-node work deferred in the open items.
"""
e_watson_knew_king_disguised = KnewAt(
subject=watson,
object_=stmt_king_is_count_von_kramm,
moment=moment_watson_sees_king_unmasked,
**_p(57, watson),
)
e_watson_knew_irene_has_photo = KnewAt(
subject=watson,
object_=stmt_irene_possesses_photograph,
moment=moment_kings_visit,
**_p(78, watson),
)

What can medlit gain from the recent Holmes work?

Link: https://claude.ai/chat/4e178792-ec4f-424c-b2d9-b90a4a71e0a9

Assistant:

6/3/2026 10:14:52

Thought process

Analyzed schema architecture and identified transferable design patterns.

  • Reading the medlit schema file

  • Reading the truncated middle section of medlit schema

  • Reading the relationship section

  • Reading remaining relationship definitions

  • Now I have a thorough picture of the medical literature schema. Let me analyze what's there, what the Holmes work brings, and what the key differences and transfer opportunities are.

    What's in the medlit schema:

    Entity types:

    • Biomedical core: Disease, Symptom, Drug, Gene, Mutation, Protein, Pathway, AnatomicalStructure, Procedure, Test, Biomarker

    • Research metadata: Paper, Author, Institution, ClinicalTrial

    • Scientific method: Hypothesis, StudyDesign, StatisticalMethod, EvidenceLine, Evidence

    • Infrastructure: TextSpan (fine-grained provenance anchor), PaperMetadata

    • The relationship layer covers medical causality and therapeutics, molecular interactions, diagnostic connections, and research provenance—spanning everything from how drugs treat conditions to how genes encode proteins to how papers cite each other.

    Now I'm looking at what makes this schema particularly sophisticated: the extraction provenance tracks not just what was extracted but the entire pipeline context including model versions and checksums, the measurement class captures quantitative claims with statistical rigor through p-values and confidence intervals, and the text span provides character-level anchoring that's more precise than paragraph-level tracking.

    The schema also incorporates scientific method relationships directly from SEPIO ontologies, grounds evidence types in actual ECO terminology, and enriches study metadata with clinical context like phase and outcomes. The base medical relationships tie everything together with evidence IDs and confidence scores, while entity deduplication through SameAs and citation sentiment tracking add another layer of sophistication for handling the complexity of medical literature.

    Where this diverges from Holmes is fundamental: the medlit schema doesn't enforce domain and range constraints through Python's type system like Holmes does. Instead, it uses string-based subject and object IDs across all relationships, with constraints declared separately in MedlitDomain through a PredicateConstraints mechanism. This creates a stringly-typed dispatch pattern via a factory function and relationship type map—exactly the kind of approach Holmes explicitly avoids. The edge type resolution also returns strings rather than leveraging type information.

    The medlit schema lacks formal trait definitions for properties like symmetry or transitivity, though some relationships like InteractsWith are noted as bidirectional in comments. More significantly, medlit introduces Hypothesis as a distinct entity type with a sophisticated cluster of relationships (Predicts, TestedBy, Supports, Refutes, Generates) that models the scientific claim lifecycle—something Holmes approached differently through its Statement entity. Evidence and EvidenceLine are also first-class entities here, giving the schema a more developed scientific evidence perspective. The Hypothesis-Evidence pairing essentially serves as medlit's answer to what Holmes's Statement was attempting to capture.

    Where Holmes has a real advantage is in type enforcement at construction time. Medlit relies on string IDs for subject and object references, which means you could theoretically create invalid edges like a Treats relationship with a Disease as the subject without the type system catching it immediately. The PredicateConstraints validation happens separately and reactively rather than being baked into the schema itself.

    Beyond that, several medlit predicates have implicit properties that should be formalized as traits: SubtypeOf needs transitivity, InteractsWith and SameAs should be symmetric, Encodes has a natural inverse relationship, and Cites already has CitedBy as a separate predicate when they could be unified through trait definitions.

    The schema also lacks a truth_status mechanism for relationships—there's confidence scoring but no way to formally mark something as disputed or retracted, which is essential for medical literature where findings get contradicted. And while Hypothesis is close to Holmes's Statement concept, medlit doesn't have an explicit way to represent competing claims about the same relationship across different papers—right now assertions are implicit in the graph structure itself. is the text says X (exists: confidence field on edges + ExtractionProvenance), narrator_confidence maps to author hedging language like "suggests" versus "demonstrates" which is partially captured in ClaimPredicate.description but not formally defined, and truth_status—the graph's assessment of whether a claim is actually true—is completely absent from medlit's current schema.

    Medlit has several advantages over Holmes: it tracks multiple sources with their individual signals, includes quantitative measurements with statistical properties, incorporates scientific method entities like study design and statistical methods, maintains sophisticated extraction pipeline provenance, and captures study-level metadata that Holmes doesn't support.

    For improvements, I should adopt R6-style typed relationships where subject and object fields enforce types at construction rather than validation time, add a truth_status lifecycle to the base relationship class with states like "asserted," "disputed," "retracted," and "superseded," and formalize trait properties like Symmetric for InteractsWith and Transitive for SubtypeOf.

    The Hypothesis and Evidence structure from medlit is actually better suited than Holmes's Statement approach—individual paper claims should reference a Hypothesis, and the Hypothesis's truth_status should aggregate the supporting or refuting evidence, creating a consensus tracking mechanism. I also need to add formal hedging levels to ClaimPredicate similar to narrator_confidence, something like "speculative" through "proven," and document the design decisions and rejected alternatives explicitly.

    Now I'm prioritizing what matters most for this consultation: the stringly-typed subject/object is the biggest architectural gap, truth_status is missing from relationships, and the Hypothesis entity needs to resolve the claim-per-paper versus claim-in-aggregate conflation. Medlit has some genuinely strong patterns Holmes lacks—multi-source provenance, measurement with p-values and confidence intervals, sophisticated extraction provenance, and TextSpan with character offsets—so those are worth highlighting as well.

  • Done

Now I can do a proper comparison. The medlit schema is substantially more mature in some directions and has notable gaps in others. Here's the honest assessment.

What medlit already has that is genuinely good

The ExtractionProvenance class is impressive — git commit hash, prompt checksum, embedding model, pipeline version. That's the kind of audit trail Holmes will never need because it was annotated manually, but it's exactly what a production extraction pipeline requires and Holmes's simpler extraction_method string doesn't come close to it. The Measurement class (p-value, confidence interval, effect size, study population) handles the quantitative claims gap I identified earlier. TextSpan with character offsets is more precise than Holmes's paragraph_index. And the Hypothesis/EvidenceLine/Evidence cluster with ECO ontology IDs is a thoughtful attempt at grounding the scientific method in formal ontology.

The biggest architectural problem: R6 violation

Every relationship in medlit uses subject_id: str and object_id: str. The PredicateConstraints in MedlitDomain declare domain and range, but they're enforced separately from construction, reactively, as validation-on-demand rather than at construction time. This is exactly what Holmes's hard rule R6 prohibits, and for good reason — you can construct a Treats edge with a Disease as subject and a Drug as object, and the Python type system won't stop you. The constraint lives in MedlitDomain.predicate_constraints, not in the class itself.

The fix is the same pattern Holmes uses: subject: Drug, object_: Disease as typed Pydantic fields on Treats. The PredicateConstraints dict in MedlitDomain then becomes redundant — the constraints are already expressed in the type annotations and enforced by mypy and Pydantic. This is a meaningful refactor but it eliminates a whole class of silent bugs.

The trait system is absent

Several medlit predicates have implicit trait-like properties that should be formalized:

SubtypeOf should be Transitive — if BRCA-mutated breast cancer is a subtype of breast cancer and breast cancer is a subtype of carcinoma, the inference should be available without storing it explicitly. InteractsWith is described as "bidirectional" in its docstring but has no Symmetric trait, which means symmetry isn't mechanically enforced or traversable. SameAs should be Symmetric (and carefully considered for Transitive). Encodes has a natural inverse. None of this is formalized.

truth_status is missing from relationships

Medlit relationships have confidence (a pipeline float) but no truth_status enum. There's no formal mechanism to mark a relationship as disputed or retracted. For a domain where findings get contradicted by subsequent studies and papers get retracted, this is a genuine gap. Holmes's lifecycle — hypothetical → asserted_true | asserted_false → disputed | retracted — maps directly, with one addition: superseded makes more sense than asserted_false in medical literature, since a finding isn't wrong so much as it's been replaced by better evidence.

The conflation problem in multi-source provenance

This is the subtlest issue. Medlit uses source_papers: List[str] to aggregate multiple papers onto a single relationship. That handles consensus but loses the per-paper signal — you can't represent "three papers support this, two refute it" in the current model, because all papers are collapsed into one Treats edge.

The Holmes Statement entity solves this but in a different way. The medlit equivalent would be: each paper's claim is a separate assertion (a ClaimEdge or Assertion entity linking paper → proposition), and the proposition itself tracks its aggregate truth_status. The existing Hypothesis entity is actually close to this — a Hypothesis is a proposition that Supports and Refutes edges point at — but the current schema doesn't connect individual paper claims to a Hypothesis cleanly. The Supports and Refutes predicates go from Evidence to Hypothesis, which is right directionally, but there's no mechanism to say "this specific claim in this specific paper with this specific p-value supports this hypothesis." That's the three-layer epistemic model Holmes forced: individual assertion, source evidence, aggregate truth_status.

Author hedging is unstructured

ClaimPredicate has a description text field but no formal hedging level. The difference between "suggests," "indicates," "demonstrates," and "proves" is genuinely information-bearing in medical literature — it's the medlit analog of narrator_confidence. Something like hedge_level: Literal["speculative", "suggestive", "indicative", "demonstrated"] on the claim, separately from extraction_confidence on the pipeline, would formalize a distinction that currently lives in free text.

What to actually do

In priority order: adopt R6 (type-enforced subject/object), add truth_status to BaseMedicalRelationship, formalize traits on the predicates that have implicit ones, and redesign the multi-source provenance to separate per-paper claims from aggregate proposition status. The ExtractionProvenance machinery, the Measurement class, and the scientific method entity cluster are all worth keeping and don't need significant changes — those are where medlit is ahead of what Holmes required.

Assistant:

6/3/2026 11:01:59

You're already on Opus 4.8 — it's the currently selected model, so switching wouldn't change anything. But let me take the prompt seriously and push on whether there's a deeper layer we've been circling without naming.

I think there is one, and it's been hiding in plain sight across the whole conversation.

Every hard problem we hit — the Statement entity, the use/mention distinction, truth_status, the medlit per-paper-vs-aggregate conflation — is the same problem wearing different clothes. The recurring move is separating a proposition from the act of asserting it. Holmes needed it because Watson asserts things that aren't true. Medlit needs it because papers assert things that conflict. In both cases the bug is the same: a plain typed graph conflates "this relationship holds" with "this relationship is in the graph." Presence is assertion. That conflation is fine for a static factual domain and fatal the moment you have multiple fallible sources or any notion of belief.

So the deeper insight is that your three vectors aren't actually peers. Typing and canonical identity are about the proposition — what is being claimed, about which named things. Provenance is about the assertion — who claimed it, from where, how sure. The Statement entity is what makes that second category first-class instead of smuggling it in as edge metadata. And once you see it that way, the whole book has a cleaner spine: a high-stakes graph must represent assertions, not just facts. Naming and knowing are about facts; trusting is about assertions. Your subtitle was right a second time.

A second, sharper point: there's a layering you keep rediscovering and it has a name in logic — it's the object-language/meta-language distinction, and it's stratified. Facts about individuals (layer 1), assertions about facts (layer 2: KnewAt, Supports, Refutes, Contradicts), and in principle assertions about assertions (layer 3: a meta-analysis asserting something about the body of papers asserting things). Holmes stops at layer 2 because nested belief is rare. Medlit genuinely reaches layer 3 — a systematic review is a layer-3 object, a claim about the aggregate of claims. Your Hypothesis entity with its truth_status is groping toward being a layer-3 node without quite admitting it. If you name the stratification explicitly, the medlit "consensus" problem stops being a special case and becomes "the graph supports one more level of the same recursion."

The third thing — and this is the one I'd actually chase if I were you — is that the binary-vs-reification thread and the assertion thread are the same thread. Reification is necessary precisely when you want to predicate over a proposition, and predicating over a proposition is exactly what assertion-tracking requires. So "when do I reify?" and "when do I need provenance/belief/dispute?" have the same answer: whenever an edge needs to become the object of discourse rather than just a fact in the world. That's a single, teachable criterion that currently appears scattered across three different chapters' worth of material. Collapsing it into one principle would make the book noticeably tighter.

If you want, I can try to state that unifying principle formally — as an addition to the seven hard rules, something like an R8 that says when an edge must be promoted to a Statement — since it's the one piece of doctrine the formal definition doesn't yet pin down.

User:

6/3/2026 11:03:27

Let's take a swing at R8.

Assistant:

6/3/2026 11:05:32

Thought process

Synthesized reification doctrine into formal rule with structural grounding.

  • I'm reviewing the existing rules to understand their formal structure—each one has a bold statement followed by explanatory text grounded in the formalism. I need to match that same rigorous voice and style as I work through the remaining rules.

    The core insight is that reification becomes necessary when you need to treat an edge as an object of discourse itself, not just as a fact. The vocabulary table already captures this: reification applies when a proposition must appear in subject or object position within a higher-order predicate. So R8 should formalize this criterion precisely—distinguishing when an edge must be promoted to a Statement entity based on whether it needs to become the target of further predicates. bearing:

    There are two things people call reification:

    1. Attaching metadata to a fact (provenance, confidence) → handled by edge fields (R2 already covers this — metadata lives on edge instances).
    2. Referring to a fact as the argument of another fact → requires the fact to have an identity → this is the Statement.

    So R8 is specifically about case 2. The rule should say: an edge must be promoted to a Statement entity if and only if it appears in the subject or object position of another edge.

    But there's a subtlety. The reason you CAN'T just put the edge directly in the object position is the formal definition: E ⊆ V × T_E × V. Edges connect entities (V), not other edges. So if you want a predicate whose object is a proposition, the proposition must be a member of V — i.e., an entity. That's the Statement entity. This is forced by R3 (edges are triples over V) — you literally cannot have an edge whose object is another edge under the formal definition. So R8 is in some sense a corollary of R3 plus the desire for higher-order predication.

    Now there's also the assertion-decoupling insight. A Statement isn't just an edge with an ID — it's a proposition decoupled from assertion. When an edge is in E, its presence asserts it (closed-world assumption). But when a proposition becomes a Statement entity in V, its presence doesn't assert it; instead, the truth_status field carries the commitment separately. This is the use/mention distinction.

    So there are actually two separate triggers for promoting an edge to a Statement: higher-order predication (when the proposition needs to be the subject or object of another predicate like KnewAt or Contradicts), and non-assertion (when the proposition needs to exist without being asserted true, like a false belief or disputed claim). I'm wondering whether these are really two distinct cases or if they collapse into one — like, if you want to say "Watson believes P" where P is false, you need P to exist as an entity in the first place. t as a referable thing (trigger 1: it's the object of believes) AND P must not be asserted (trigger 2: it's false). These co-occur here.

    But can you have trigger 2 without trigger 1? Can you have a proposition that's unasserted but not the object of any higher-order predicate? E.g., a "hypothetical" proposition that nobody believes yet, just floating. Hmm. If nothing refers to it and it's not asserted, why is it in the graph at all? It seems like a proposition only earns its place in the graph either by being asserted (a normal edge) or by being referred to (a Statement). A floating unasserted unreferenced proposition is inert. So trigger 2 alone (without trigger 1) doesn't really motivate a Statement — actually it does in one case: when you want to record that a proposition was considered (e.g., a hypothesis posed but not yet tested). But even then, the thing that makes it worth recording is usually that something points at it (a paper that posed it, TestedBy, etc.).

    Can you have trigger 1 without trigger 2? Yes — "Watson knew that Holmes was the clergyman" shows a proposition that's both true and the object of a higher-order predicate like KnewAt, so the Statement's truth_status is asserted_true while still being referenced. This means trigger 1 (higher-order predication) is the primary, clean trigger, while trigger 2 (suspended assertion) is really just a consequence that Statements make possible.

    So the core rule should focus on higher-order predication: an edge must be promoted to a Statement whenever it appears as the subject or object of another predicate. The secondary benefit is that this promotion decouples presence from assertion — the Statement's truth_status field now carries the truth commitment that an ordinary edge's mere presence would otherwise carry. And I should add the inverse discipline: don't promote an edge to a Statement just to attach metadata to it. ural. Now I'm checking that R8 aligns with R3 and the existing Reification vocabulary entry—R3 establishes that edges are triples over V, and the vocab entry explains that reification promotes an edge to a named entity so it can be a predicate argument, which is necessary and intentional when a proposition appears in subject or object position. R8 should formalize this into an iff rule and add the constraint against unnecessary reification. Looking at how R6 and R7 are formatted, I need to draft R8 in that same style with a rule statement followed by explanatory text.

    For R8, I want to capture four things: the promotion criterion tied to higher-order predication, the formal grounding in R3 (since edges can't be endpoints under the triple structure, only entities can), the prohibition against reifying for metadata purposes, and the consequence that a Statement's presence is mention rather than assertion, with truth_status carrying the commitment. I'm still deciding whether R8 should include precedence guidance.

    The key distinction I'm working through: an edge instance is an asserted proposition, while a Statement is a proposition independent of assertion. When we promote an edge to a Statement, we're making that proposition referenceable as an entity. The criterion should be precise—an edge gets promoted if and only if some predicate needs to take that proposition as its subject or object. The key insight is that propositions shift from being asserted facts (edges in the graph) to being mentioned objects of discourse (Statement entities in the vertex set) precisely when they need to become subjects or objects of other predicates — that's the use versus mention distinction. I'll call this R8 and frame it as: promote an edge to a Statement entity exactly when a predicate must range over the proposition it expresses. An edge asserts something under the closed-world assumption, while a Statement is a referable object whose truth status is tracked explicitly. Statement` merely to attach provenance, confidence, or epistemic metadata to it. That's what R2 handles — those attributes belong directly on the edge itself. Reification works for making claims about a proposition, not for adding metadata to one. Mixing these up recreates the exact overhead this design avoids. The real test is simple: if something in the graph needs to reason about or discuss a fact, it becomes a Statement; if it's just being recorded, it stays an edge.

    Now I'm thinking through how this distinction maps to the structural boundary between first-order facts about individuals and higher-order claims about claims, and whether the current framing of R8 is hitting all four intended purposes — the layer distinction, the formal derivation, the prohibitions, and the practical criterion for designers. the iff criterion holds up even for hypotheses — they only enter the graph when something references them through higher-order predicates, and truly orphaned statements shouldn't exist anyway. I should also verify that Statements can occupy both subject and object positions in these higher-order relations like Contradicts or Supports, which my phrasing already accounts for.

    Now I'm checking whether "presence in the graph equals assertion" holds against the AssertionMixin design. The schema does allow edges to carry truth_status via AssertionMixin, defaulting to asserted_true, so presence technically means asserted-by-default rather than unconditionally asserted. This creates a slight wrinkle: both edges and Statements can have truth_status, which complicates the clean story I was building.

    But actually, this clarifies R8. The truth_status field serves two distinct purposes in Holmes: AssertionMixin on direct factual edges (like Knows or DisguisedAs) lets you mark a first-order fact as disputed or retracted, while Statement.truth_status tracks the commitment of a reified proposition being referenced. So assertion-decoupling through truth_status is somewhat orthogonal to reification—you can have a directly-asserted edge that's disputed without needing to reify it.

    This sharpens R8 considerably: the actual trigger for promoting something to a Statement is purely higher-order predication, not the need to suspend assertion, since AssertionMixin already handles that on plain edges. So I need to correct my draft—the distinguishing feature of a Statement isn't that it carries truth_status, but that it's a member of V and can be referenced as an object. The clean distinction is that truth_status (assertion-decoupling) is available on both edges and Statements, but only Statements enable higher-order predication. The key insight is that entities in V don't assert anything by mere existence—a Person entity existing doesn't assert a fact—so a Statement reified as an entity must carry its truth_status explicitly, whereas an edge gets truth-by-presence (unless overridden by AssertionMixin). This means R8 should focus purely on higher-order predication as the trigger for reification, not on decoupling assertion. ities assert nothing by their presence. And the TRIGGER for using a Statement is higher-order predication (R8's criterion), not the desire to suspend truth (which AssertionMixin handles for plain edges).

    Let me also reconsider: is it ever right to reify specifically to suspend truth, when there's no higher-order predication? Given AssertionMixin exists, no — if you just want to mark a first-order fact disputed, use AssertionMixin on the edge. Reify only for reference. This keeps the iff clean and is consistent with the schema's actual design.

    So R8 is really about when a predicate needs to take a proposition as its argument — that's the only reason to promote an edge to a Statement entity. Propositions can exist as edges (asserted by presence) or as named Statement entities (carrying explicit truth status), and the choice between them is determined by whether higher-order predicates need to reference them, not by any desire to mark them as disputed or retracted. ions of every edge are filled by members of $V$ — never by members of $E$. An edge cannot be the endpoint of another edge. Therefore the moment a proposition must occupy the subject or object role of a higher-order predicate — KnewAt, Contradicts, Supports, Refutes — it cannot remain a bare edge; it must be promoted to a Statement so that it becomes a member of $V$ and can fill that role. This is the only thing that forces promotion.

    The converse is a hard prohibition. Do not promote a proposition to a Statement in order to hang provenance, confidence, or epistemic metadata on it; that is R2's responsibility and those fields belong on the edge instance directly. Do not promote it merely to mark it as disputed or retracted — that's what AssertionMixin on the edge handles. Reification exists to enable higher-order predication, not to annotate facts; using it for anything else recreates the overhead this model was designed to avoid.

    The core principle is simple: does the graph need to reason about this fact, or just record it? Facts that are merely recorded stay as edges. Facts that agents can know, studies can dispute, or later claims can contradict become Statement nodes. That distinction is where the boundary lies between the first-order graph of individual facts and the higher-order graph of claims about claims.

    I'm satisfied this captures the full doctrine now. It reads naturally and matches the house style. This is a "big" rule like R6, which is appropriate given how central it is to the whole model. All four paragraphs do distinct work: the formal constraint, the two hard prohibitions, and the teachable principle. I should keep them all. For presentation, a single rule of this length works better inline than as a separate file. I'm going to keep the presentation tight—just the rule itself with matching formatting, then a couple of sentences noting how R8 pulls together the scattered material, and offer the CWA bifurcation note as a brief follow-up rather than embedding it in the rule itself. I'm organizing the response around R8 as the core rule, with a framing sentence that ties it to the broader reification doctrine, then wrapping it up with a brief note on what this accomplishes and offering to integrate it into the markdown file.

  • Done

Here's a swing at it, written to slot into the existing hard-rules section and to match the voice of R1–R7.


R8. Promote a proposition to a Statement entity exactly when a predicate must take it as subject or object.

A proposition can live in the model in two forms. As an edge instance $(v_1, p, v_2) \in E$ it is asserted by presence: under the closed-world assumption, the edge being in the graph is the claim that the relationship holds (an AssertionMixin may downgrade this to disputed or retracted, but the default of presence is assertion). As a Statement entity in $V$ it is merely named: an entity asserts nothing by existing, so a Statement must carry its commitment explicitly in a truth_status field. The two forms are the use and the mention of the same proposition.

Which form is required is fixed by R3 and the formal definition, not by taste. Because $E \subseteq V \times T_E \times V$, the subject and object positions of every edge are filled by members of $V$ — never by members of $E$. An edge cannot be the endpoint of another edge. Therefore the moment a proposition must occupy the subject or object role of a higher-order predicate — KnewAt, Contradicts, Supports, Refutes — it cannot remain a bare edge; it must be promoted to a Statement so that it becomes a member of $V$ and can fill that role. This is the only thing that forces promotion.

The converse is a hard prohibition. Do not promote a proposition to a Statement in order to hang provenance, confidence, or epistemic metadata on it; that is R2's responsibility, and those fields belong on the edge instance directly. Do not promote it merely to mark it disputed or retracted; that is what AssertionMixin on the edge is for. Reification is the instrument for predicating over a proposition and the wrong instrument for annotating one. Reifying for any reason other than higher-order predication reintroduces precisely the overhead this model exists to reject (see Non-Goals: Not RDF/OWL).

The rule collapses to one question the schema designer asks of every fact: does the graph need to talk about this fact, or only to record it? A fact the graph merely records stays an edge. A fact the graph must talk about — one an agent can know, a study can dispute, a later claim can contradict — becomes a Statement. That question is the boundary between the first-order graph of facts about individuals and the higher-order graph of claims about claims, and R8 is the rule that places each fact on the correct side of it.


Two notes on what this does. It pulls the reification material out of the vocabulary table's prose (where it currently lives as a soft "intentional, not an anti-pattern" aside) and turns it into enforceable doctrine, and it makes the Statement entity's existence derivable from R3 rather than a standalone design choice — which is a stronger position for the book to argue from.

One consequence I deliberately kept out of the rule itself, because it's semantic rather than structural: once propositions can exist as Statements without being asserted, your closed-world assumption bifurcates. Absence of a Statement means the proposition was never entertained; presence of an unasserted Statement means it was entertained and not (yet) committed to. That distinction is exactly what the medlit consensus problem needs — "no claim either way" versus "claimed and disputed" — but it's a note for the semantics section, not an eighth rule. I can draft that paragraph too if you want it to live somewhere explicit.

User:

6/3/2026 11:19:08

One thing that worries me about this is that after promotion, we now have two objects (an edge and a Statement entity) representing the same thing, which smells like a DRY violation. Maybe we can connect them in some iron-clad way so that there's no chance they forget about each other or their statuses drift apart. Let me press on this just a bit more: it should be possible to query (very efficiently) either an edge or a Statement to find its mate, or None if it doesn't have one. An edge will have no mate before promotion, and a Statement will have no mate if it's purely speculative or hypothetical.

Assistant:

6/3/2026 11:21:55

Thought process

Reconciled DRY violation through content-addressed identity and single-authority status.

  • The user is pointing out a critical flaw: when an edge gets promoted to a Statement, we end up with two separate representations of the same thing that can diverge. They need a robust bidirectional link between edges and their corresponding Statements, with efficient lookups in both directions—and crucially, edges start without a mate, while Statements only get one if they've actually been asserted rather than remaining purely hypothetical.

    But before I lock into a two-object solution with bidirectional pointers, I should step back and question whether that's even the right architecture. The DRY violation the user is worried about—storing the triple (subject, predicate, object) twice—might be a symptom that we shouldn't have two objects at all. Let me map out the real design options here, because some are far cleaner than others.

    The bidirectional pointer approach is what the user is leaning toward: give the edge a statement_id field and the Statement a back-reference. But this creates problems. The triple gets duplicated across both objects, which is exactly the DRY violation they're concerned about. More critically, if edges are immutable (R7), you can't add a statement_id after the edge is constructed—you'd need the Statement to exist before you even build the edge, or you'd have to break immutability. And even then, keeping two mutable pointers in sync is fragile; drift is the exact risk they're trying to avoid.

    The better answer is to collapse this into a single object: the Statement is the edge, and promotion from edge to Statement is just a status change, not a copy. This eliminates the duplication entirely. The tension is that the formal model treats edges and Statements as different mathematical sorts—edges live in E, Statements in V—so I need to figure out how to reconcile that constraint with a unified object.

    Option C might be the resolution: treat the Statement as the canonical home of the proposition, and the "edge" as a derived view. An asserted fact is just a Statement with truth_status set to asserted_true; the edge set E isn't stored separately but computed from Statements that meet that condition. One source of truth, no drift, no duplication—but it's a significant architectural shift that makes every fact a Statement.

    The user's current schema treats edges as first-class objects, so Option C would require a bigger refactor than they might want right now. Still, it's the DRY-correct answer and worth presenting honestly since their instinct about duplication is pointing in exactly this direction. Option D is another angle: when you promote an edge, it's removed from E and replaced by the Statement in V, so the two never coexist and the "mate" problem dissolves entirely.

    But here's the catch with D: if an asserted-and-referenced proposition (like Watson knowing that Holmes was the clergyman, and it actually being true) needs to work both as a traversable fact in the first-order graph AND as a referenceable Statement object, then removing the edge during promotion means that first-order fact vanishes from the edge graph. Any traversal looking for Holmes's disguises would then have to also check asserted Statements, which brings you right back to Option C. So D doesn't actually escape the problem—it just defers it.

    The real choice comes down to...

    Option A gives you two objects with efficient mate lookup, but violates DRY and risks drift. Option C is DRY-correct by treating edges as derived views over asserted Statements, but requires the full all-Statements refactor. The user explicitly wants the two-object world with queryable mates, so I should answer that directly—but I should also flag that the DRY smell they're detecting points toward a cleaner solution: maybe not having two objects at all. Good advice means solving what they asked while surfacing the better underlying question.

    There's a middle path worth exploring: if a Statement's ID is a deterministic hash or canonical string of its proposition (subject, predicate, object), then you can compute a Statement's ID directly from an edge without storing pointers or maintaining a lookup table—just hash the edge's components and probe the Statement store. That gives you O(1) mate lookup with zero duplication.

    The real elegance here is that the link isn't stored at all; it's derived from content, like Git's content-addressed storage. Since both the edge and Statement represent the same proposition, they'll always compute the same key, so drift becomes impossible—there's nothing to sync or maintain.

    There's still duplication of the triple itself across both stores, which feels like a DRY violation, but the content-derived shared key guarantees they can't disagree about the triple's identity. What can still drift is the status fields—the edge's truth_status versus the Statement's truth_status. Content-addressing solves the link problem, but the status mismatch remains.

    To fix status drift, I need a single source of truth. Either the Statement alone carries truth_status and the edge defers to it, or I collapse both into one object entirely. If I go with the first approach, an unpromoted proposition's status comes from its presence plus any AssertionMixin, but once it's promoted to a Statement, that Statement becomes the authority—though this is getting complicated to reason through.

    Actually, stepping back: both the edge and Statement represent the same proposition, so their truth_status should mean the same thing—the graph's commitment to that proposition. Storing status in both places creates drift risk, so the fix is to make the Statement the authority once it exists, since it's the more formal representation.

    The edge can't easily ignore its truth_status field because AssertionMixin bakes it in as a frozen field, but the cleanest solution is to treat the Statement as the canonical object and the edge as its asserted projection—with content-addressed identity based on the subject, predicate, and object.

    Rather than storing edges separately, I'd index Statements by their asserted truth_status, making the edge a view of currently-asserted Statements. This eliminates duplication and drift: there's one object per proposition, one status, and the edge IS the Statement itself. Checking whether a Statement has an edge mate becomes a simple O(1) lookup of its own truth_status field, while a speculative Statement with no edge is just one whose truth_status is hypothetical.

    But I'm realizing the user's "mate" concept assumes edges and Statements are distinct objects linked together, which doesn't quite map to this model—I need to clarify what "mate" actually means in their mental model before I can properly translate between the two approaches. the one-object model, there's no mate because there's no second object. So the one-object model dissolves the question rather than answering it. That's the strongest kind of answer to a DRY worry — "you're right that the duplication is bad; here's how to not have it, at which point your mate-pointer problem evaporates."

    But I should be honest that this one-object model is the "all-Statements" refactor, which is a bigger change to their current schema (where BaseEdge and Statement are separate). The user may not want to refactor that far. So I should present BOTH:

    1. The honest DRY-correct answer: don't have two objects; make the edge a projection of the Statement (or equivalently, all facts are Statements; assertion is a status). This dissolves the mate problem.
    2. If they want to keep two objects, the iron-clad link is content-addressed identity — derive the Statement's ID from the proposition so the link can't drift, and put status in one place (the Statement) so status can't drift.

    Even with content-addressing, keeping two objects means you must pick a single authority for status, or it drifts. Now for the efficiency piece: with a content-addressed key derived from subject, predicate, and object, I can look up the Statement from an Edge in O(1) with a single index probe, and vice versa by computing the key and probing the Edge table. so the content-addressed approach handles the edge case perfectly — a hypothetical Statement with no corresponding asserted edge simply returns None on lookup, which is exactly the right behavior. Now I need to reconcile this with the formal definition and R8. The two-object content-addressed approach is a refinement of R8 that adds a content-addressed identity convention while keeping E and V separate, which aligns with the formal definition. The one-object model would be cleaner but requires revising the 6-tuple itself since E becomes derived rather than primitive. I should present the content-addressed two-object solution first since it directly answers the question with minimal disruption to the current schema, then mention the one-object alternative as a deeper option.

    I want to frame this as a principle or rule addition to R8 about identity discipline, phrased crisply but not over-formalized unless the user wants it as R9. This mirrors the pattern they've responded well to—answering the immediate question while pointing toward the better underlying question.

    I should also verify the "iron-clad" claim about content-addressing. The link itself is iron-clad because it's derived from content, not data—two identical propositions will always compute the same key, so you can't accidentally create a mate relationship that violates the constraint.

    But the user's real worry is status drift, which content-addressing alone doesn't solve. I need to address that separately. The solution is to establish a single authority for status: either the status lives only on the Statement and the edge has no truth_status field, or an unpromoted edge defaults to asserted-by-presence, and disputing or retracting it promotes the edge to a Statement where status then lives.

    This is actually elegant—disputing a fact is itself a higher-order claim about the claim, so it should naturally trigger promotion to a Statement anyway. That means edges wouldn't need to carry truth_status at all; assertion is just presence, and anything non-default requires a Statement. Let me verify this holds against the current Holmes schema, which puts AssertionMixin on direct factual edges so they can be disputed or retracted—but under this logic, disputing should itself be a Statement-level act. puting a fact means something in the graph is talking about that fact — which is exactly the trigger for promotion to a Statement. So AssertionMixin on edges is arguably redundant with / in tension with R8: it lets you suspend assertion without promoting, but suspending assertion is a meta-claim that "should" be a Statement.

    So there's a clean position: edges have no truth_status, and presence alone signals assertion in a closed-world model. The moment a proposition needs any status beyond plain assertion—disputed, retracted, hypothetical, believed-by-someone—that's because something is talking about it, which means promoting to Statement where the status lives. This eliminates AssertionMixin entirely and solves the status-drift problem by ensuring status has only one home: either implicit in edge presence or explicit on the Statement. R8 becomes the single gateway for all non-trivial epistemics, and the user's DRY concern vanishes because status can no longer live in two places simultaneously. A proposition stays as a bare asserted edge until the moment we need to reason about it, at which point it becomes a Statement with its own status field. point it becomes a Statement carrying explicit status, and — crucially — the edge either goes away (Option C/D, one object) or remains purely as the assertion-projection (Option E, two objects but status only on Statement).

    So now the cleanest two-object design is:

    • Edge: subject, predicate, object, provenance, extraction_confidence, narrator_confidence. NO truth_status. Presence asserts.
    • Statement: content-addressed id = f(subject, predicate, object), + truth_status. The single authority for any non-presence status.
    • Mate link: derived from content (shared key), not stored. O(1) both ways.
    • Status authority: the Statement, when it exists. When no Statement exists, status is "asserted by presence." These two never overlap-and-disagree because the edge has no status field to disagree with.

    When a fact is both asserted and referenced, the edge's presence and the Statement's truth_status naturally align — but if that fact later becomes disputed, I need to decide whether to remove the edge entirely or keep it as a historical record while the Statement becomes the live authority. The tension is whether edge-presence should mean "currently asserted" or "was ever asserted." the indicator of asserted_true status. The edge's existence and the Statement's truth_status stay synchronized through a simple invariant: an edge exists if and only if the Statement is asserted_true, which I can enforce at write time. This means the edge set is actually just a projection of asserted Statements—so I don't need to store edges separately; the graph itself becomes a view over the Statements table filtered to asserted_true entries.

    So the solution unifies everything: content-address the proposition by identity, keep status in exactly one place, and let edge presence itself assert truth. An edge carries no separate truth_status field; its existence is the assertion. Any non-asserted status gets its own Statement object, which the system already handles through the dispute/belief mechanism.

    This creates a clean invariant where asserted-true propositions appear as edges, and everything else lives as Statements — meaning the edge graph becomes a derived view over the asserted Statement store, collapsing the two-object design back into one and eliminating the DRY violation entirely.

    For the mate query: given an edge, I check if a Statement exists by computing the content-addressed key and probing the Statement store — either because something references it or because I materialize Statements for all asserted facts. Given a Statement, its edge mate exists if and only if the truth_status field reads asserted_true, which is a constant-time lookup that can't drift since the edge's existence is defined by that exact field.

    I should frame this as answering the literal question first — content-addressed identity gives O(1) undriftable mate lookup — then show how pressing on the status-drift concern forces a stronger invariant that reveals the edge is just a view over asserted Statements, so the DRY violation isn't mitigated, it's eliminated, and the two-object framing was the thing to release. I'll write this as substantial prose without excessive structure, maybe a light code illustration of the content-addressed key, keeping it disciplined and focused on the technical depth the user is clearly engaged with. I need to make sure I'm directly addressing their specific questions: the O(1) lookup in both directions using content-addressed keys, the case where an edge has no mate before its Statement is asserted, and the case where a Statement remains unmatched if it's purely speculative. Their instinct to lock these together is sound, but the real insight is that storing pointers is what rots—deriving the connection from content addresses keeps everything consistent because the truth status lives in one place and can't drift. I'll validate their DRY concern, answer the literal question about querying, then show how the status-drift problem forces a single source of truth, which ultimately means the edge and Statement collapse into one unified object.

    I'm aiming for something meaty but focused—maybe seven to nine paragraphs with a small code example—since this is the conceptual payoff and they keep pushing for rigor, so the length is justified as long as each paragraph earns its place.

  • Done

Your nose is right — the smell is real, and I think it's telling you something stronger than "add a link." Let me answer the literal question first, then show why pressing on it dissolves the two-object framing entirely.

The link should be derived, not stored. The reason a stored pointer can drift is that it's data, and data can be wrong. So don't store it. Give a proposition a content-addressed identity: the Statement's id is a deterministic function of the proposition it expresses.

key(subject_id, predicate_type.__name__, object_id)  ->  "stmt:king:DisguisedAs:count_von_kramm"

Now the edge and its Statement don't need to remember each other, because the relationship isn't a fact about them — it's computable from either one alone. Given an edge, you compute the key and probe the Statement store; given a Statement, its subject/predicate_type/object_ fields already are the edge content, so you compute the same key and probe the edge store. Both directions are a single indexed lookup — O(1), or in Postgres a shared key column with a unique index on each table. "Mate or None" is just "does a row with this derived key exist over there." An edge before promotion: no Statement with that key yet → None. A purely hypothetical Statement: no asserted edge with that key → None. Exactly your two boundary conditions, with nothing to keep in sync, because two representations of the same proposition compute the same key by construction. You cannot create a wrong mate-link; there is no mate-link to be wrong. This is Git's trick — the address is the hash of the content, so the index can't rot.

That kills link drift. But you also said "their statuses drift apart," and content-addressing does nothing for that. If the edge carries an AssertionMixin.truth_status and the Statement carries a truth_status, you have two homes for one fact's commitment, and they can disagree. No clever key fixes that. The only fix is to have one home.

So put status in exactly one place, and let R8 tell you which. Here is the move: an edge carries no truth_status at all. Its presence is the assertion — full stop, closed-world. The moment a proposition needs any status other than plain-asserted — disputed, retracted, hypothetical, believed-by-someone-who-might-be-wrong — that need is by definition something in the graph talking about the proposition, which is R8's promotion trigger. Disputing a fact is a meta-claim about the fact. So non-trivial status never lives on an edge; it lives on the Statement, and only on the Statement. This also retires AssertionMixin as a thing on edges, which was the second home for status and therefore the source of the drift you smelled.

Follow that to its conclusion and the invariant becomes crisp: an edge exists if and only if the proposition's status is asserted_true. Any other status means presence would be a lie, so the edge must not be present; the Statement carries the real status alone. Asserted-true is the one status that needs no Statement, because presence already says it.

And now the two objects collapse. If "edge exists ⟺ asserted_true," then the edge set isn't independent data — it's the projection of the Statements whose status is asserted_true. The first-order graph you traverse is a view: SELECT ... FROM statements WHERE truth_status = 'asserted_true', indexed by subject and by object for traversal. There is one stored object per proposition — the Statement — and "the edge" is simply the name you give it while walking the asserted slice as if it were a fact. The DRY violation doesn't get mitigated; it stops existing, because there was only ever one object. Your "find the mate" query degrades into a field read: a Statement's edge-mate exists iff its own truth_status is asserted_true. That can't drift, because the edge's existence is defined by that field rather than mirrored from it.

So the honest answer to "connect them iron-clad" is: don't connect two things — notice they were one thing seen from two layers. The edge is the proposition under use (asserted, traversable as fact); the Statement is the same proposition under mention (named, statusable, referenceable). Content-addressing is how you'd bridge them if you insisted on keeping both stored, and it's a perfectly good intermediate design if you don't want to refactor the current schema yet. But the thing the DRY instinct is actually pointing at is that assertion should be a status of a proposition, not a separate kind of object, and once it is, there's nothing to keep in sync.

The cost — and I'll be straight about it — is the one we flagged earlier: this is the all-Statements world, so your closed-world assumption now reads two ways (no Statement at all = never entertained; Statement present, not asserted_true = entertained, not committed), and every first-order traversal goes through the asserted-projection index instead of a standalone edge table. For Holmes that's free. For medlit at scale it's a real indexing decision, though the BFS-as-indexed-join analysis says it's the same cost as a separate edge table with a WHERE clause.

If you want, the next swing is rewriting R8 — or adding the corollary — to encode this: edges carry no truth_status; presence is assertion; all non-asserted status lives on a content-addressed Statement; the edge graph is the asserted projection. That's a tighter and more defensible piece of doctrine than the version I gave you, and it earns the DRY guarantee instead of bolting a sync rule on top.

User:

6/3/2026 12:28:48

Suppose you have two different medical papers that have different kinds of metadata about the same edge, like one says it's true and one says it's false, or they assign different numerical measures to it, or whatever. Does that invalidate the idea of a single computed key for the corresponding Statement?

Assistant:

6/3/2026 12:30:10

Thought process

Reconciled conflicting claims by separating proposition identity from per-source assertions.

  • I'm grappling with a real tension here: when the same clinical claim appears in different papers with conflicting evidence—one showing strong efficacy, another showing weak or no effect—does the content-addressed design break down? The key would be identical since it's derived from the same subject, predicate, and object, but the metadata diverges significantly. I need to think through what the key actually identifies versus what the metadata captures.

    The key identifies the proposition itself—the claim that Olaparib treats breast cancer. Papers A and B are making assertions about that same proposition, which is exactly why they can disagree. If they generated different keys, I'd lose the ability to recognize they're talking about the same thing. So the single key isn't a flaw; it's the whole point—it's the convergence point where conflicting claims meet and become comparable.

    The real issue I'm uncovering is different: I've been conflating the proposition's properties with the assertion's properties. The response rates and p-values aren't properties of the proposition itself—they're properties of each paper's specific claim about it. This mirrors the medlit distinction between per-paper and aggregate data that came up earlier, and now it's surfacing at the schema level. I need to separate the layers properly.

    The Statement should only carry the core proposition and the graph-level aggregate truth judgment. The per-paper measurements, p-values, polarity, and individual truth assessments belong on the assertion edges—the connections from each paper to the Statement. That way, the Statement remains the reified proposition, while the assertion objects capture the act of claiming itself. what is genuinely a property of the proposition-as-such: its identity (the content key) and its aggregate truth_status (the graph's synthesized stance, possibly "disputed" precisely because A and B disagree).

    This means my earlier framing was imprecise in a way this question correctly catches. I conflated "status on the Statement" with "the metadata." Let me separate cleanly:

    1. Proposition identity → content key → the Statement (one object). ✓ Not invalidated.
    2. Per-paper metadata (measurement, polarity, p-value, hedge, which paper, what section) → assertion edges, one per (paper, Statement) pair. These are NOT content-addressed by (subject, predicate, object) alone — they're keyed by (paper, Statement), or more precisely each is its own claim with its own provenance.
    3. Aggregate truth_status → on the Statement, computed/curated from the incident assertion edges. "disputed" is the natural value when supporting and refuting assertions coexist.

    The single key doesn't get invalidated — it actually clarifies that A and B are disagreeing about the same proposition rather than talking past each other. The conflicting claims are distinct assertion records all pointing to that one Statement. Now I'm wondering whether assertion edges themselves should be content-addressable, and if a paper asserts the same proposition in multiple places, whether that counts as one assertion with multiple evidence spans or several separate assertions. Now I'm seeing how the stratification works across layers: individuals like Olaparib and BreastCancer form the base, the proposition Treats(Olaparib, BreastCancer) sits at layer 2 as a single Statement with a content key, and then assertions about that proposition live at layer 3 where Paper A supports it and Paper B refutes it. The truth_status on the Statement itself represents the graph's own commitment at that layer.

    But here's the key insight: in a multi-source setting, truth_status isn't something you set directly — it's really an aggregate rolled up from all those layer-3 assertions. Holmes could collapse this distinction because there was essentially one asserter, so the proposition's status and the narrator's claim were the same thing. Medlit forces them apart by having many asserters, which actually reveals that Holmes was underemphasizing the consensus dimension all along. So the refined principle is that truth_status should be derived from or at least reconciled with the incident assertion edges in multi-asserter domains, while the granular details like polarity, measurement, hedge, and provenance stay attached to the individual assertions, never to the Statement itself.

    For numerical measures specifically — when you have response_rate=0.59 from one study and 0.32 from another, those can't both be scalars on a single Statement. They belong on their respective assertion edges. If you want an aggregate like a meta-analytic pooled estimate, that's a computed value that could live on the Statement or on a dedicated meta-analysis assertion, which is itself just another layer-3 object — a paper whose assertion pools the others, creating a beautiful recursive structure where a meta-analysis becomes an assertion about the proposition that's informed by the assertions it aggregates.

    For the "one says true, one says false" case, both papers resolve to the same Statement key, so truth_status becomes "disputed" — derived from the mixed polarity of incident assertions. This makes disagreement first-class and queryable: you can find all disputed Statements by looking for those with mixed-polarity assertions. This conflict detection only works because both claims share the same key; if they got different keys, the graph would hold both "Treats=true" and "Treats=false" as separate facts and never surface the contradiction.

    But there's a real risk: two papers might describe subtly different propositions — say, one treats BRCA-mutated breast cancer while the other treats general breast cancer. If they map to the same canonical entity, they'll look contradictory when they're actually about different populations. This comes down to whether your entities and predicates are fine-grained enough to capture the distinction. If population matters, it needs to be part of the proposition itself — either as a more specific entity or as a qualifier that reifies the claim into a higher-arity proposition.

    The core point: the content key is only as reliable as the proposition's precision. Two claims share a key if and only if they're truly the same proposition; if they share a key but mean different things, that's a schema-granularity problem, not a key problem. The per-paper metadata was never a property of the proposition itself — it belongs on the assertion edges, one per source. The Statement holds the identity and aggregate status, and the single key is what makes that work. not on the Statement itself — they belong on the assertion edges, keyed to each source. That's the fix: Statement holds only identity and aggregate status (derived from consensus), while per-source claims and their metadata stay attached to the edges that connect papers to that Statement. The granularity problem remains the real constraint — if two different claims map to the same key, the proposition needs sharper definition (population, dosage, context), otherwise you're conflating distinct assertions. on the Statement, and even truth_status becomes a derived aggregate rather than a primitive. I should own that refinement gracefully — it's consistent with the earlier medlit analysis (per-paper vs aggregate), so it's a convergence, not a contradiction.

    Also I should make sure to address: does the EDGE (asserted projection) still make sense? In the multi-asserter world, "edge exists iff asserted_true" — but asserted_true is now an aggregate judgment. So the first-order edge exists iff the graph's aggregate stance is asserted_true. A disputed proposition has no first-order edge (you wouldn't want to traverse it as a plain fact), only a Statement with status=disputed and a fan of conflicting assertions. That's coherent and good: the clean first-order graph contains only the consensus-true facts; disputes live in the Statement layer above it. Traversing the base graph gives you settled knowledge, while examining a Statement reveals the full contested picture — that's a useful property for high-stakes applications where you want the first-order graph to represent currently believed facts.

  • Done

No — it vindicates the single key rather than invalidating it. The disagreement is only detectable because both papers resolve to the same key. If conflicting claims got different keys, the graph would quietly hold "Treats(Olaparib, BreastCancer) = true" and "...= false" as two unrelated facts and never notice they collide. The content key is precisely the convergence point where disagreement becomes visible.

What the question correctly catches is that I was loose earlier when I let "status and metadata live on the Statement." In a single-asserter world like Holmes that's harmless, because the narrator's claim and the proposition's status are the same thing. The moment you have many fallible asserters, they come apart, and the per-paper data was never a property of the proposition to begin with. It's a property of the act of claiming it. So the layering has to be:

The proposition — Treats(Olaparib, BreastCancer) — is one Statement with one content key. It carries identity and nothing source-specific. Each paper's claim is a separate assertion edge pointing at that Statement: Paper A supports it carrying response_rate 0.59, p 0.001; Paper B refutes it carrying 0.32, p 0.08. The measurements, the p-values, the polarity, the hedge language, the provenance — all of that lives on the assertion edge, one per (source, Statement) pair, because all of it is data about a particular claiming event, not about the proposition. Two papers, two assertion edges, one Statement.

The Statement's own truth_status then stops being a primitive you set and becomes an aggregate derived from its incident assertions. Mixed polarity among the assertions is exactly what makes the status disputed — that's the consensus computation falling out of the structure for free, and it's the medlit per-paper-versus-aggregate fix from earlier arriving at the schema level. "Find every disputed claim" becomes "find Statements whose incident assertions disagree in polarity." A pooled meta-analytic estimate, if you want one, isn't a special field either — a meta-analysis is just one more assertion edge, typically weighted higher and citing the others. The recursion closes cleanly: claims about the proposition, and claims that synthesize those claims, are the same kind of object at different weights.

This also sharpens the "edge exists iff asserted_true" invariant from the last message. In the multi-source world, asserted_true is the aggregate verdict, so the first-order edge — the thing you traverse as a plain fact — exists only when the graph's synthesized stance is settled-true. A disputed proposition has no first-order edge at all; it lives one layer up as a Statement with a fan of conflicting assertions hanging off it. That's a desirable property for high-stakes work: traversing the first-order graph gives you the currently-settled knowledge, and asking about a Statement gives you the contested picture behind it. The dispute never silently contaminates the fact layer.

There is one real failure mode hiding in your example, and it's worth naming because it's the thing that would make this machinery cry wolf. Suppose Paper A means Olaparib treats BRCA-mutated breast cancer and Paper B means breast cancer generally. If both resolve to the same canonical BreastCancer object, they get the same key and the graph reports a contradiction that isn't one — they're simply different propositions. That's not a flaw in content-addressing; it's a signal that your proposition isn't precise enough to carry the distinction the claims actually turn on. The fix is the qualified-claim machinery we circled earlier: the population is part of the proposition, so it has to be part of what the key is computed over — a more specific object entity, or a reified claim with a population qualifier (a higher-arity proposition you've chosen not to flatten). Two claims should share a key if and only if they are the same proposition; when genuinely different claims collide on one key, the key is telling you your schema's granularity is too coarse for the claims you're trying to represent. The key is exactly as sharp as the proposition behind it, no sharper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment