diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7222c0d..e7c3300 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Bun uses: oven-sh/setup-bun@v2 diff --git a/.github/workflows/deploy-schema.yml b/.github/workflows/deploy-schema.yml index d820fa6..31d4477 100644 --- a/.github/workflows/deploy-schema.yml +++ b/.github/workflows/deploy-schema.yml @@ -26,7 +26,7 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Configure Pages uses: actions/configure-pages@v5 diff --git a/.planning/00-START-HERE.md b/.planning/00-START-HERE.md new file mode 100644 index 0000000..44ecbc3 --- /dev/null +++ b/.planning/00-START-HERE.md @@ -0,0 +1,96 @@ +# Predicate Semantics — Start Here + +**What this is:** a proposal to add a small set of machine-readable fields to our predicate specs +(`packages/predicates`) so the Intuition knowledge graph can render itself, query across relationship +hierarchies, synthesize reverse edges, and price disagreement. Grounded in 30 years of knowledge-graph +research, pruned hard to stay simple. + +**Status:** Draft for team review · **Date:** 2026-06-30 · **Owner:** Architecture + +--- + +## TL;DR — what we're proposing + +Add **9 new fields** to `PredicateSpec` (flat, all optional, additive/non-breaking), plus two soft-ships +(`claimType`, `supersededBy`), plus the existing `marketPattern`: + +| Field | Type | What it unlocks | +|---|---|---| +| `objectKind` | `entity \| claim \| literal` | frontend renders any edge with no per-predicate code; makes reification visible | +| `literalType` *(audit)* | `url \| image \| date \| number \| text` | completes the rendering contract for literals (RDF typed-literals lesson) | +| `polarity` | `positive \| negative \| neutral` | one signed reputation score across all predicates; sentiment-colored UI | +| `inverse` | `PredicateKey` | store one edge, serve both directions (different reverse predicate) — with a **canonical direction** per pair | +| `isSymmetric` | `boolean` | store one edge, serve both directions (same predicate) — with **mint-time canonical ordering** | +| `temporalNature` | `permanent \| state \| event` | freshness model — which edges rot, which are forever | +| `contradicts` | `PredicateKey[]` | detect & price disagreement (trust ⊥ distrust, **same subject/object pair**) — the differentiator | +| `isTransitive` | `boolean` | closure/reachability queries (containment trees, org charts) — never sentiment | +| `specializes` | `readonly PredicateKey[]` | roll-up queries & reputation (employedBy ⊑ affiliatedWith); DAG, not tree | +| `claimType` *(soft-ship)* | `factual \| evaluative` | fact-vs-opinion market & UI semantics | +| `supersededBy` *(soft-ship, audit)* | `PredicateKey` | deprecation forwarding — pickers redirect, old edges stay legible | +| `marketPattern` *(exists)* | `depositional \| attributive \| comparative` | per-predicate market mechanics | + +**Deliberately deferred or cut** (no consumer yet / redundant): `isAsymmetric`, `isFunctional`, +`isInverseFunctional`, `isReflexive`/`isIrreflexive`, `equivalentTo`, `verifiability`, property chains, +`isHierarchical`. See `predicate-fields/deferred-and-cut.md`. + +**Guiding rule:** a field ships only if it names a *consumer that exists today* (frontend, indexer) or an +*emerging* one (reputation/markets). We deliberately stay inside the cheap, forward-chainable **OWL 2 RL** +reasoning profile and don't encode inference we can't compute at scale. + +--- + +## Reading order + +1. **`00-START-HERE.md`** (this file) — the summary and reading order. +2. **`predicate-spec-decisions.md`** — ⭐ *canonical.* The pruned field set, flat-vs-nested decision, the + final `PredicateSpec`, pros/cons, and what we're explicitly not doing. + ✅ Read together with **`predicate-spec-audit.md`** — the adversarial review pass, **all findings now + folded into the docs** (2026-07-01). It confirmed the overall shape and drove: mint-time + canonicalization for symmetric/inverse triples (§5.2 — the blocking economic fix), the precise + pair-level definition of `contradicts` (P3), the `literalType` + `supersededBy` fields, `specializes` + widened to an array, the complete 14-rule validation set (§5.1), and the normative OWL/RDF + serialization table (§5.3). +3. **`predicate-fields/`** — one document per field with multiple use cases and a confidence score + (see `predicate-fields/README.md` for the rubric and the score table). This is where each field earns + its place; the scoring overturned one earlier decision (`isAsymmetric`). +4. **`predicate-semantics.md`** — the original full brainstorm / map of everything possible. Background; + its §5 schema sketch is superseded by #2. +5. **`predicate-semantics-foundations.md`** — the research grounding: OWL/RDF property characteristics in + depth and 30 years of KG lineage. Read for the "why these and not others." +6. **`predicate-backend-integration.md`** — how the backend (`alpha/backend`) consumes these fields: + schema, indexing, query optimization, performance. **Headline:** `kg.predicates` already has dormant + slots for `is_symmetric`/`is_transitive`/`inverse_predicate_id`/`metadata`, and `kg.triples` already + has `inferred`/`confidence` for synthesized edges — so this is mostly *activation*, not construction. + Three gaps: seed real metadata, add the missing columns, build the consumers. +7. **`predicate-backend-implementation-plan.md`** — the ticket-ready build plan: 7 workstreams + (package → schema → seed → query features → API → consumers → tests), phased P0–P5 with dependencies, + acceptance criteria, and rough sizing. Start here when we're ready to execute. +8. **`predicate-query-optimization-spec.md`** — deep dive on the hardest read path: gathering metadata & + resolving identity by traversing connected triples from a root item (the Spotify→Apple Music case). + Predicate registry/parsing, the exact `kg.triples` indexes, the equivalence-class engine (query-time + vs materialized union-find), and the batched gather algorithm. **Headline:** identity/metadata is + moving from embedded JSON-LD (`data_resolved.sameAs`) to triple-native edges; this makes that fast. + +If you read only one: **`predicate-spec-decisions.md`**. + +--- + +## Open questions for the team + +1. **`claimType`** — soft-ship now (define + populate where obvious), or hold entirely until a market + branches on it? +2. **`polarity`** — keep an explicit `neutral` value (for `neutralOn`) distinct from "absent," or collapse? +3. **`isInverseFunctional`** — pull forward to *start collecting* dedup/identity data before the resolution + engine exists (data only, no auto-merge)? +4. **`marketPattern`** — its three values lack an external anchor and overlap conceptually with `claimType`; + does it need its own semantics-clarification doc before we lean on it for market mechanics? +5. **`contradicts`** — hard contradiction only (trust/distrust), or do we also model soft *tension* + (skepticalOf/trust) as a separate relation? + +--- + +## The one-sentence pitch + +*Every predicate carries just enough machine-readable structure that the graph can render itself, query +across relationship hierarchies, synthesize reverse edges, and price disagreement — using the proven flat +vocabulary of 30 years of knowledge-graph research, and nothing more than that.* diff --git a/.planning/forum-post-intro.md b/.planning/forum-post-intro.md new file mode 100644 index 0000000..5ea61de --- /dev/null +++ b/.planning/forum-post-intro.md @@ -0,0 +1,166 @@ +# Build the knowledge graph off-chain first: an intro to the Intuition packages (and where they're heading) + +*Draft forum post — for the Intuition community forum. Written developer-to-developer.* + +--- + +Hey everyone 👋 + +I want to introduce something we've been building that I think changes how you'll build on Intuition: a +set of focused TypeScript packages that let you **model the entire knowledge graph off-chain first**, for +free, and only settle on-chain when it actually matters. + +This post is a proper introduction — what the packages are, the concepts behind them, how to think about +them — plus a peek at where we're taking them next. It's long-ish because there's a real mental model here, +but each piece is simple on its own. Grab a coffee. + +One thing up front so nobody's surprised: what's out today is the *packages* — the client-side TypeScript +layer you build against. The backend that indexes and serves the graph isn't open source yet. It's coming, +and soon, but I didn't want to bury it: everything I describe below runs without it, which is kind of the +whole point. + +## The shift: off-chain first + +If you've built on-chain before, you know the tax: **every piece of data costs a transaction.** Put a fact +on the graph? Pay. Connect two things? Pay again. That model quietly kills experimentation — you don't +prototype freely when every idea has a gas bill attached. + +The packages flip this. You can define atoms, wire up relationships, compute IDs, build whole creation +flows and UIs — **entirely off-chain, entirely free** — and bring things on-chain only when they're worth +settling. The official quickstart is dry-run by design: no wallet, no API key, no live submission needed. + +```bash +cd examples/hackathon-quickstart +bun run dev +``` + +Off-chain to move fast. On-chain when it counts. Everything below serves that idea. + +## The core concepts (the whole graph in three words) + +Intuition's knowledge graph is built from a tiny, composable vocabulary: + +- **Atoms** — the nouns. A person, a song, an org, a URL, a concept. Anything you can point at. +- **Predicates** — the verbs / relationships. `member of`, `authored by`, `same as`, `trusts`. +- **Triples** — a statement made of the two: **subject → predicate → object.** + `Alice → member of → Acme`. That's an edge in the graph. + +Two more ideas that matter a lot: + +- **Deterministic IDs.** Every atom, predicate, and triple has a **content-addressable ID** you can compute + *before* anything touches a chain. Same content → same ID, everywhere, forever. This is the part that + quietly makes everything else work: your off-chain graph and the eventual on-chain graph speak the same + identifiers, so there's no "import/sync" cliff later. You're always building the real thing. +- **`sameAs` identity.** Different atoms can point at the same real-world thing (the same song on Spotify + and Apple Music, the same person across two profiles). `sameAs` links them so the graph can treat them as + one identity without forcing everyone to agree on a single canonical atom up front. + +If you internalize atoms / predicates / triples / deterministic IDs / sameAs, you understand Intuition. + +## The packages, by layer + +We split everything into small, ESM-only packages so you install **only the layer you need** while +converging on shared vocabulary and IDs. Here's the map and how to think about each: + +### 🧩 Semantic / data modeling — *what things are and how they relate* + +- **`@0xintuition/schema-org`** — a pinned [schema.org](https://schema.org) vocabulary (types + properties, + with inherited-property lookup). This is the shared dictionary so a "MusicRecording" means the same thing + in your app and mine. +- **`@0xintuition/classifications`** — Intuition classification specs and **Creation Profiles**: the + recommended fields for a given kind of thing, plus the relationships that make sense for it. This is what + lets you render a correct "create a song" form without hardcoding it. +- **`@0xintuition/predicates`** — the canonical **predicate registry**: the blessed set of relationships, + their deterministic IDs, display helpers, and which predicates suit which entity types. When you need a + relationship, you reach here. + +### 🔑 Identity — *stable names for everything* + +- **`@0xintuition/ids`** — deterministic, content-addressable IDs for atoms, predicates, triples, + counter-triples, and OAuth atoms. The backbone that makes off-chain-first coherent. + +### 🛠️ Builder helpers — *the ergonomic layer* + +- **`@0xintuition/primitives`** — high-level builders and validators for atoms, claims, triples, and + discovery. Instead of assembling raw structures, you call `buildTripleByName(...)` and get a validated + result. This is where most app code lives. + +### ⚙️ Protocol plumbing — *when you're ready to go on-chain* + +- **`@0xintuition/protocol`** — core contract ABIs, event parsers, interaction helpers. +- **`@0xintuition/deployments`** — typed chain metadata and deployment addresses. +- **`@0xintuition/periphery`** — periphery ABIs and bridge/router helpers. +- **`@0xintuition/curves`** — bonding-curve math for the market/staking side. +- **`@0xintuition/react`** — React hooks for the core protocol interactions. + +The nice thing about splitting it this way: you can live entirely in the top three layers while prototyping, +and only reach for the protocol layer once your app actually owns a wallet and a submission flow. (Reading +the *global* graph back — everyone's atoms and triples, not just the ones you built locally — is the job of +the indexing backend, which, as mentioned, is still on its way. For now you're modeling and settling; the +querying side lands when that opens up.) + +## How they work together (a tiny walkthrough) + +Say you're building a music app. Start from a Creation Profile: + +```ts +import { musicRecordingCreationProfile } from '@0xintuition/classifications/creation/music-recording' + +const fields = musicRecordingCreationProfile.fields // render your atom form +const relationships = musicRecordingCreationProfile.relationships // suggest valid triples +// e.g. MusicRecording -> inPlaylist -> MusicPlaylist +``` + +Use `fields` to render inputs, `relationships` to suggest predicates, `primitives` to build and validate +the triple, and `ids` to know its ID before you ever sign. Your UI is generated **from the package data** +instead of an app-local shadow copy that drifts. When (and if) you're ready, the protocol layer settles it +on-chain — with the exact same IDs you've been using all along. + +That's the loop: **model → validate → (optionally) settle**, with deterministic identity the whole way. + +## Where this is heading: predicates that describe themselves + +Here's the part I'm most excited to build *with you*. + +Right now a predicate's meaning mostly lives in its name and description — great for humans, opaque to +machines. We're exploring giving each predicate a little **machine-readable metadata about how the +relationship behaves**, so the graph can actually reason: + +- **Symmetric?** `Acme → partner of → BigCo` implies the reverse automatically. +- **Has an inverse?** `parent org` ⇄ `subsidiary of` — assert one, get the other. +- **Transitive?** venue in district in city ⟹ venue in city, as one query. +- **Carries sentiment?** `endorse` is positive, `distrust` negative — so reputation becomes something the + graph computes instead of every app hardcoding it. +- **What's on the other end?** entity vs. raw value vs. *another claim* — one field that lets a frontend + render any edge with zero custom code. + +Circle back to `sameAs`: imagine a song created from a Spotify link, and later someone connects the Apple +Music version **as a real edge**. Now "give me every link for this track," "what does the community know +about it," and "are these two items secretly the same thing?" all become answerable by walking connected +triples. That last one — knowing two nodes are the same reality — is exactly what machine-readable +predicates unlock, and it's everywhere: identity, products, tokens, provenance. + +This is grounded in the same ideas 30 years of knowledge-graph work (RDF, OWL, schema.org) landed on — we're +adapting the proven parts and cutting the over-engineered ones. And the vocabulary isn't settled, which is +why I'm posting now. + +## Come build with us + +An open invitation: + +- 🧪 **Prototype something.** Everything's on npm (`@alpha`) and runs off-chain out of the box. Start from + the quickstart, define some atoms and triples, break things, show us. +- 💬 **Bring your use case.** Music, identity, DeFi, social, science, provenance — every domain has + relationships that want to be first-class. Tell us yours and we'll model the predicates together. +- ❓ **Ask hard questions.** Especially "wait, how would this handle…" — those are how we find the gaps. +- 🔧 **Argue the design.** Are `symmetric` / `transitive` / `inverse` the right words or too academic? What + relationship properties are we missing? Where's the line between expressive and over-complicated? Reply and + push on it. + +The dream is a knowledge graph you can build for free, that renders itself, queries across relationships +intelligently, knows when two things are the same, and can even represent disagreement — settled on-chain +only when it matters. I think we can get there, and it's more fun to figure out with you. + +What would you build first? 👇 + +— *[your name]* diff --git a/.planning/predicate-backend-implementation-plan.md b/.planning/predicate-backend-implementation-plan.md new file mode 100644 index 0000000..4abf06f --- /dev/null +++ b/.planning/predicate-backend-implementation-plan.md @@ -0,0 +1,223 @@ +# Implementation Plan: Predicate Attributes End-to-End + +**Status:** Build plan — ticket-ready +**Companion to:** `predicate-backend-integration.md` (the mapping/why), `predicate-spec-decisions.md` (the fields) +**Date:** 2026-06-30 + +Goal: take the 8 decided fields (+ soft-ship `claimType`, + `marketPattern`) from *declared in the package* +to *stored, seeded, queried, and consumed* in the backend. The backend has dormant schema slots but **zero +implemented behavior** — so this plan covers the whole path: package → schema → seed → query features → +consumers, with sequencing, dependencies, and acceptance criteria. + +--- + +## 0. Verified ground truth (what we're building on) + +- **KG is a separate, optional Postgres** (`DATABASE_KG_URL`), written by the projection + `core_entities.rs`; it "graceful no-ops when absent." (verified: `core_entities.rs` header.) +- **The KG schema is NOT in committed migrations.** The numbered set `indexing-services/migrations/000–049` + is the **TimescaleDB** event store. `kg.predicates`/`kg.triples` exist only in the test fixture + `embeddings-job/tests/backfill_integration.rs`, described as "post-#437 shape." → **Prerequisite P0: + locate/own the KG migration mechanism.** +- **The migration runner** is psql over `migrations/*.sql`, tracked in `schema_migrations`, keyed by + `$DATABASE_URL` (`Dockerfile.migrations`). The KG DB needs its own runner/dir (different instance). +- **Predicate write path** `upsert_kg_predicate_lazy` (`core_entities.rs:485`) inserts only + `(id, slug, label)` `ON CONFLICT (id) DO NOTHING` — it can never write or update our metadata. +- **Enabler:** the package already computes a stable **atom id per predicate** (`PREDICATE_IDS` in + `predicates.ts`, via `calculateAtomId`). So a generated seed can carry **both `id` and `slug`** — the + id↔slug mapping the integration doc worried about is solved at generation time. +- **Package build** runs generator scripts (`../../scripts/*.mjs`) then `tsc`; specs live (hand-authored) + in `src/generated/specs/*.ts`; `PredicateSpec` is in `src/types.ts`; `definePredicateRecord` in + `record.ts`. +- **Counter-triple scaffolding** already exists on `kg.triples` (`is_counter_triple`, `sibling_triple_id`) + — the instance-level expression of our predicate-level `contradicts`. + +--- + +## 1. Workstreams overview + +| WS | Name | Repo | Outcome | +|---|---|---|---| +| **A** | Package = source of truth | `packages/predicates` | fields declared, validated, backfilled; seed artifact emitted | +| **B** | KG schema | `alpha/backend` (KG migrations) | columns/tables/indexes/constraints for all fields | +| **C** | Seed pipeline | both | package metadata lands in `kg.predicates` and stays in sync | +| **D** | Query features | `alpha/backend` | reverse-edge, transitive, roll-up, contradiction, validation actually work | +| **E** | API exposure | `alpha/backend/api` | predicate metadata + new query powers reach clients | +| **F** | Consumers | `recommendation-service` | polarity/transitive/specializes feed ranking | +| **G** | Test & observability | both | correctness invariants + perf guarded | + +--- + +## 2. WS-A — Package (source of truth) + +| ID | Task | Files | Size | +|---|---|---|---| +| A1 | Add fields to `PredicateSpec` (flat): `polarity`, `objectKind`, `literalType`, `temporalNature`, `claimType`, `isSymmetric` (exists), `isTransitive` (exists), `inverse: PredicateKey`, `specializes: readonly PredicateKey[]` (array — audit B1), `contradicts: PredicateKey[]`, `supersededBy: PredicateKey` | `src/types.ts` | S | +| A2 | `definePredicateRecord`: defaults + the **complete 14-rule derivation/validation set** (decision record §5.1 — incl. `specializes` acyclicity, contradiction closure down the hierarchy, no-contradiction-with-ancestor, `polarity ∧ isTransitive` error, `literalType ⟹ objectKind='literal'`, symmetric+transitive allow-list); typed-key refs resolve to known keys (build fails otherwise) | `src/record.ts` | M–L | +| A2b | **Canonical direction derivation** (audit A1): for each inverse pair, derive the canonical member (deterministic rule); export it in the record + seed so builders and the indexer agree | `src/record.ts` | S | +| A3 | Migrate `inversePredicate: string` → `inverse: PredicateKey` across specs | `src/generated/specs/*.ts` | S | +| A4 | Backfill **mechanical** fields across 133 specs: `polarity`, `objectKind`, `temporalNature` (scriptable, obvious values) | specs | M | +| A5 | Backfill **curated** fields: `specializes`, `contradicts`, `inverse` pairs, `claimType` (human review) | specs | M | +| A6 | New generator `scripts/generate-predicate-seed.mjs` → emits `dist/predicate-seed.json` (and optional `.sql`): one row per predicate with `{ id: PREDICATE_IDS[key], slug: key, label, description, isSymmetric, isTransitive, inverse→id, isCanonicalDirection, specializes→[id], contradicts→[id] (hierarchy-closed), polarity, objectKind, literalType, temporalNature, claimType, marketPattern, supersededBy→id }` — references resolved to atom ids | `scripts/`, `package.json` build | M | +| A7 | Tests: derivation/validation unit tests (one per §5.1 rule); seed snapshot test | `src/*.test.ts` | S | +| A8 | **Mint-time canonicalization in builders** (audit A1 — blocking, spans `@0xintuition/primitives` + `ids`): symmetric predicates → canonical subject/object ordering (by atom ID) before triple-ID computation; inverse pairs → normalize to the canonical direction from A2b. Both user intents resolve to one triple, one market | `primitives`, `ids` | M | + +**Acceptance:** `bun run build` emits a deterministic seed artifact; invalid specs (one-sided inverse, +asymmetric `contradicts`, dangling key ref) fail the build. + +--- + +## 3. WS-B — KG schema migrations + +> Lands in the KG migration mechanism resolved in **P0**, against the `DATABASE_KG_URL` instance. + +| ID | Task | Detail | +|---|---|---| +| B1 | Confirm existing columns | `is_symmetric`, `is_transitive`, `inverse_predicate_id`, `metadata` present in live KG (not just fixture) | +| B2 | Add predicate columns | `polarity text`, `object_kind text`, `market_pattern text`, `superseded_by_predicate_id text` (+ `claim_type`/`temporal_nature`/`literal_type` in `metadata` JSONB per the column-vs-JSONB heuristic) | +| B3 | Add `temporal_nature` | JSONB-first (`metadata->>'temporalNature'`); promote later if filtered | +| B4 | Inter-predicate join tables | `CREATE TABLE kg.predicate_contradictions (predicate_id text, contradicts_id text, PRIMARY KEY (predicate_id, contradicts_id))` — both directions, **hierarchy-closed at seed** (§5.1 rule 7). `CREATE TABLE kg.predicate_specializations (predicate_id text, specializes_id text, PRIMARY KEY (predicate_id, specializes_id))` — `specializes` is an array/DAG (audit B1), so a single column no longer fits | +| B5 | CHECK constraints | `polarity IN ('positive','negative','neutral')`, `object_kind IN ('entity','claim','literal')`, `market_pattern IN ('depositional','attributive','comparative')` | +| B6 | Reconcile backend-only flags | `is_hierarchical` (we cut it — derive `is_transitive AND inverse_predicate_id IS NOT NULL` at seed, or drop), `is_market` (superseded by `market_pattern` — backfill then deprecate), `is_social` (map from `category` or leave) | +| B7 | Triple indexes | `(subject_id, predicate_id)`, `(object_id, predicate_id)`, `(predicate_id)`, partial `WHERE inferred = false` (see integration doc §5.5) | + +**Acceptance:** migrations apply cleanly on a fresh KG DB and on the post-#437 shape; fixture updated to match. + +--- + +## 4. WS-C — Seed pipeline (the missing link) + +| ID | Task | Detail | +|---|---|---| +| C1 | Seed loader | A backend step that reads `predicate-seed.json` (pinned package version) and upserts `kg.predicates` **`ON CONFLICT (id) DO UPDATE`** of the metadata columns; `metadata = kg.predicates.metadata || EXCLUDED.metadata` (merge). Implement as a small Rust seed binary in `projections` (has KG pool) or a generated SQL migration. | +| C2 | Reconcile with lazy path | Keep `upsert_kg_predicate_lazy` as-is (`DO NOTHING`, for unknown chain predicates). Seed owns metadata via `DO UPDATE`. Document the division: lazy = discovery, seed = truth. | +| C3 | Inter-predicate seeds | Populate `kg.predicate_contradictions` (both directions, hierarchy-closed — the package emits the closed set, the backend stores it verbatim) and `kg.predicate_specializations` from the artifact. | +| C4 | Ordering | Seed runs on deploy, idempotent, independent of ingestion order (keyed by atom id from the package). | +| C5 | CI sync | Package release regenerates the artifact; backend pins the package version; a CI check fails if the committed seed is stale vs the package. | + +**Acceptance:** after seed, every enshrined/proposed predicate row in `kg.predicates` carries correct +metadata; re-running the seed is a no-op; changing a spec + re-seed updates the row. + +--- + +## 5. WS-D — Query features (make the fields do work) + +Default to **query-time** (no data migration); materialize only measured-hot paths. + +| ID | Feature | Approach | Reads | +|---|---|---|---| +| D1 | Reverse-edge view (`isSymmetric`, `inverse`) | query-time rewrite: symmetric ⇒ match `subject_id=X OR object_id=X`; inverse ⇒ present reverse rows under the inverse predicate's label | needs B7 `object_id` index | +| D2 | Transitive closure (`isTransitive`) | bounded `WITH RECURSIVE` (depth cap ~8) helper | B7 `(subject_id,predicate_id)` | +| D3 | Roll-up (`specializes`) | load shallow hierarchy from `kg.predicates`; rewrite predicate filter to `predicate_id IN (descendants)` | B7 `(predicate_id)` | +| D4 | Contradiction detection + counter-triple wiring | the §5.4 join over `kg.predicate_contradictions`; on triple insert, if predicate `contradicts` an existing sibling `(subject,object)`, set `is_counter_triple=true`, link `sibling_triple_id` | join table + triple indexes | +| D5 | `objectKind` validation | at ingestion/API write, enforce `object_type` matches: `claim ⇒ object_type='triple'`, `literal ⇒` value, `entity ⇒ 'node'` | `object_type` column exists | +| D6 | (Phase 4) Materialize hot paths | inferred reverse edges + closure table, written `inferred=true` + provenance; **excluded from aggregates that count the origin**; **confidence = min over the derivation chain** (audit C3 — never copied, never 1.0), and inferred edges never re-enter inference across rules without a depth bound | partial indexes | +| D7 | Duplicate-triple backstop (audit A1) | detect symmetric duplicates (`⟨A,P,B⟩`+`⟨B,P,A⟩`, P symmetric) and inverse-pair duplicates (`⟨A,P,B⟩`+`⟨B,Q,A⟩`, `Q = inverse(P)`) minted before canonicalization (A8) or via raw protocol calls; link via `sibling_triple_id`-style reference; define aggregate-stake presentation policy | triple indexes | + +**Where D1–D3 live:** the KG read layer the API uses (e.g. `api/src/clients/kg-workflows.ts` / KG read +crate). Centralize as reusable query builders so API + recommendation share them. + +**Acceptance:** golden-query tests for each (reverse view returns origin's counterpart; closure respects +depth cap; roll-up expands correctly; contradiction query finds conflicts; invalid object_kind rejected). + +--- + +## 6. WS-E — API exposure + +| ID | Task | Files | +|---|---|---| +| E1 | `GET /api/predicates`, `GET /api/predicates/:id` returning the rendering contract (`objectKind`, `polarity`, `temporalNature`, `marketPattern`, relations) | `api/src/routes/`, new `predicates/` module | +| E2 | Extend triple hydration to include predicate metadata | `api/src/triples/surreal.ts` (+ KG read path, ENG-11475) | +| E3 | Query params: filter by `polarity`, opt-in roll-up expansion, `includeInferred` toggle (default exclude) | `api/src/routes/triples.ts` | + +**Acceptance:** frontend can render an edge purely from the predicate response; inferred edges excluded by +default and labeled when included. + +--- + +## 7. WS-F — Consumers (recommendation/reputation) + +| ID | Task | Detail | +|---|---|---| +| F1 | polarity-aware scorer | new scorer reading `kg.predicates.polarity`; signed reweight; **exclude inferred** to avoid double count | +| F2 | transitive trust chains | use `isTransitive` (D2) for "trusted-by-the-trusted" social signal | +| F3 | specializes interest widening | expand interest predicates via D3 | + +(Recommendation currently reads only vaults/posts/events — these add KG reads. Scorer internals from +exploration are **unverified**; confirm before building.) + +--- + +## 8. WS-G — Testing & observability + +- G1 package unit tests (A7). +- G2 migration apply tests + update `backfill_integration.rs` fixture. +- G3 update `e2e-tests/08-dual-write-consistency.e2e.test.ts` for new predicate fields. +- G4 query-correctness tests for D1–D5. +- G5 **invariant test: no double-counting** — synthesized/rolled-up edges never summed with origins. +- G6 metrics: inferred-edge counts, closure-query latency, seed drift check. + +--- + +## 9. Sequencing + +``` +P0 Prereqs ─ resolve KG migration mechanism; pin package↔backend versions + │ +P1 Foundation (additive, no behavior change) ── WS-A, WS-B, WS-C + │ outcome: kg.predicates fully populated; dormant→live data +P2 Read features (query-time, no data migration) ── D1,D2,D3 + E1,E2 + │ outcome: reverse views, closure, roll-up, predicate API. MEASURE. +P3 Validation + contradiction ── D4,D5 (+ counter-triple alignment), E3 +P4 Optimize hot paths ── D6 materialization + index tuning (only where P2 shows pressure) +P5 Consumers ── WS-F recommendation signals +``` + +P1–P2 are low-risk and unlock most of the value (rendering contract + roll-up + reverse views). Everything +after is opt-in optimization and new product surface. + +--- + +## 10. Cross-cutting invariants & risks + +- **No double-counting** (biggest correctness trap): inferred + rolled-up edges excluded from aggregates + that already count the origin. Enforced by G5 and "exclude inferred by default." +- **No split-stake duplicates** (audit A1): one fact, one triple, one market. Mint-time canonicalization + (A8) is primary; D7 detect-and-link is the backstop. Ship A8 **with or before** any reverse-edge + synthesis feature — synthesis without canonicalization encourages fragmentation. +- **Confidence decay** (audit C3): inferred-edge confidence = min over the derivation chain; inferred + edges don't re-enter inference unbounded. +- **Conflict detection is pair-level** (audit A2): the D4 join fires on same ⟨subject, object⟩ with + disjoint predicates. Attester-level disagreement about an object is a `polarity` aggregate, not a + contradiction — don't conflate the two queries. +- **OWA**: contradiction and "current state" read only explicit assertions; never infer negation from absence. +- **Replay safety**: seed and lazy paths both idempotent; seed `DO UPDATE` vs lazy `DO NOTHING` must not fight. +- **KG-optional**: all features graceful when `DATABASE_KG_URL` absent (matches current projection behavior). +- **Schema drift**: confirm live KG vs fixture (P0); resolve the predicate-FK discrepancy. +- **Version coupling**: backend seed pins a package version; CI guards drift (C5). + +--- + +## 11. P0 prerequisites to unblock (answer these first) + +1. Where do KG migrations live / how is the `DATABASE_KG_URL` schema versioned (the post-#437 mechanism)? +2. Is the fixture shape (`is_symmetric` etc.) actually deployed, or still in flight in #437? +3. Predicate FK: does `kg.triples.predicate_id` reference `kg.predicates(id)` in the live DB? +4. Has the API committed to the KG read path (ENG-11475), or do we extend SurrealDB queries meanwhile? +5. Recommendation-service scorer internals — verify before WS-F. + +--- + +## 12. Rough sizing + +| Phase | Effort (eng-weeks, rough) | Risk | +|---|---|---| +| P0 prereqs | 0.5 (mostly investigation) | unblocks everything | +| P1 foundation | 2–3 | low (additive) | +| P2 read features | 2–3 | low–med (query correctness) | +| P3 validation/contradiction | 2 | med (ingestion path, counter-triple) | +| P4 optimization | 1–2 | med (write amplification) | +| P5 consumers | 1–2 | low (additive reads) | + +These are order-of-magnitude; the curated backfill (A5) and counter-triple alignment (D4) are the most +estimate-sensitive. diff --git a/.planning/predicate-backend-integration.md b/.planning/predicate-backend-integration.md new file mode 100644 index 0000000..1c5409b --- /dev/null +++ b/.planning/predicate-backend-integration.md @@ -0,0 +1,315 @@ +# Backend Integration: Activating Machine-Readable Predicate Fields + +**Status:** Draft for review +**Companion to:** `predicate-spec-decisions.md` (the field set) +**Backend mapped:** `alpha/backend` (branch `kames/simplify-feed`), 2026-06-30 +**Scope:** how the 8 shipped fields reach the backend, get stored/indexed, and optimize queries + +> ⚠️ **Verification caveat.** The KG schema below is read from the test fixture +> `indexing-services/crates/embeddings-job/tests/backfill_integration.rs` (it references PR "#437"). The +> field `is_symmetric` appears **only** in that fixture, not in committed migrations — so the canonical +> `kg.predicates` shape should be confirmed against the live `DATABASE_KG_URL` migrations before building. +> The architecture, write path, and `core_entities.rs` details are verified from source. + +--- + +## 1. Executive summary — the schema already anticipated this + +The most important finding: **the backend is already shaped for this work, but the fields are dormant.** + +`kg.predicates` already declares `is_symmetric`, `is_transitive`, `is_hierarchical`, +`inverse_predicate_id`, and a `metadata jsonb` column. `kg.triples` already has `inferred boolean` and +`confidence numeric` — purpose-built for *synthesized* edges — plus `sibling_triple_id` / +`is_counter_triple` scaffolding for counter-claims. So we are not adding net-new infrastructure. There +are exactly **three gaps**: + +1. **Seed** — nothing writes *real* metadata into `kg.predicates`. The ingestion path lazy-upserts only + `(id, slug, label)` and `ON CONFLICT DO NOTHING`, so even pre-seeded rows never receive our fields. +2. **Populate the new fields** — `polarity`, `objectKind`, `temporalNature`, `specializes`, `contradicts`, + and a real `marketPattern` enum have no home yet (columns or JSONB). +3. **Consume** — nothing reads predicate metadata. No reverse-edge synthesis, no transitive closure, no + roll-up; the recommendation service doesn't read `kg.predicates` at all. The graph's *semantics* are + currently invisible to queries and ranking. + +This doc addresses all three, with the query/performance work concentrated in gap 3. + +--- + +## 2. Current architecture (verified) + +Event-sourced, Rust ingestion → Postgres read models, with a TypeScript API. + +``` +chain events ──> TimescaleDB event_store (hypertable, append-only) + │ (rindexer-ingestion → event_data JSONB) + ▼ + projections (Rust, tokio + sqlx; Coordinator-supervised) + │ CoreEntitiesProjection dual-writes: + ├──> TimescaleDB `term` (denormalized, legacy) + ├──> SurrealDB (legacy, retiring) + └──> KG Postgres (canonical): kg.nodes, kg.predicates, kg.triples, kg.events + │ + embeddings-job ──> search.documents (pgvector) + │ + API (Hono/TS) reads SurrealDB today, KG fallback in progress (ENG-11475) + recommendation-service (Rust/Axum) reads vaults + posts + events (NOT predicates) +``` + +### Verified schema (KG Postgres) + +`kg.predicates` — `backfill_integration.rs:134`: +```sql +id text PK, slug text UNIQUE, label text, description text, +inverse_predicate_id text, +is_transitive boolean NOT NULL DEFAULT false, +is_symmetric boolean NOT NULL DEFAULT false, +is_hierarchical boolean NOT NULL DEFAULT false, +is_social boolean NOT NULL DEFAULT false, -- backend-only, not in our spec +is_market boolean NOT NULL DEFAULT false, -- backend-only, not in our spec +metadata jsonb NOT NULL DEFAULT '{}', +created_at, updated_at +``` + +`kg.triples` — `backfill_integration.rs:151`: +```sql +id text PK, status text DEFAULT 'active', visibility text DEFAULT 'public', created_by text, +subject_id text, subject_type text DEFAULT 'node', +predicate_id text REFERENCES kg.predicates(id), predicate_type text DEFAULT 'node', +object_id text, object_type text DEFAULT 'node', +edge_kind text DEFAULT 'claim', source text, source_uri text, +confidence numeric(6,5), -- ready for synthesized-edge confidence +inferred boolean NOT NULL DEFAULT false, -- ready for synthesized edges +provenance jsonb DEFAULT '{}', metadata jsonb DEFAULT '{}' +-- (+ sibling_triple_id / is_counter_triple per the counter-triple work) +``` + +### Verified write path + +`indexing-services/crates/projections/src/projection/dual/core_entities.rs:485` — +`upsert_kg_predicate_lazy()`: +```sql +INSERT INTO kg.predicates (id, slug, label) +VALUES ($1, $1, $1) +ON CONFLICT (id) DO NOTHING +``` +The chain emits `predicate_id` as a **bytes32 atom hash**; pre-seeded predicates (`pred_follows`, …) keep +their curated `slug`/`label` because `DO NOTHING` short-circuits. **Consequence:** this path can *never* +introduce or update our metadata — seeding must be a separate, slug-keyed upsert that runs `DO UPDATE` +(see §4). Note also a discrepancy to resolve: the code comment says `predicate_id` has **no FK** to +`kg.predicates`, but the fixture declares `REFERENCES kg.predicates(id)`. Confirm which is live before +relying on referential integrity. + +--- + +## 3. Field → backend home + +| Field (spec) | Backend today | Recommendation | +|---|---|---| +| `isSymmetric` | ✅ `is_symmetric` column | reuse as-is | +| `isTransitive` | ✅ `is_transitive` column | reuse as-is | +| `inverse` | ✅ `inverse_predicate_id` column | reuse; store the **predicate atom-id**, mapped from our typed `PredicateKey` at seed time | +| `specializes` (array, per audit B1) | ❌ | join table `kg.predicate_specializations(predicate_id, specializes_id)` — a DAG needs many-to-many; mirrors the contradictions table (a `text[]` column also works, but the join table keeps both inter-predicate relations uniform) | +| `contradicts` | ❌ (but `is_counter_triple`/`sibling_triple_id` scaffolding exists) | add join table `kg.predicate_contradictions(predicate_id, contradicts_id)` — many-to-many, symmetric; **seeded with the hierarchy-closed set** (audit B2 rule 7: `P ⊑ Q ∧ Q ⊥ R ⟹ P ⊥ R` expanded at seed time, so the conflict join never walks `specializes` at query time) | +| `polarity` | ❌ | **dedicated column** `polarity text` — it's a hot query/filter axis (reputation, feed) | +| `objectKind` | ❌ | **dedicated column** `object_kind text` — drives validation + render, queried often | +| `literalType` (audit A3) | ❌ | `metadata->>'literalType'` JSONB — rendering/validation reads it via the registry, not as a SQL filter | +| `temporalNature` | ❌ | `metadata->>'temporalNature'` JSONB first; promote to column if it becomes a query filter | +| `claimType` | ❌ | `metadata->>'claimType'` JSONB (soft-ship; low traffic) | +| `supersededBy` (audit D1) | ❌ | `superseded_by_predicate_id text` (mirrors `inverse_predicate_id`; nullable, rare) | +| `marketPattern` | ⚠️ only `is_market boolean` | add `market_pattern text` (3-value); reconcile/retire the boolean | +| — | `is_hierarchical` exists | our spec **cut** this (derivable). Either derive it at seed (`is_transitive AND inverse_predicate_id IS NOT NULL`) or drop the column | +| — | `is_social` exists | backend-only flag; map from our `category` at seed time or leave to backend | + +**Column vs JSONB heuristic:** promote a field to a real column when it's a *query filter or index target* +(`polarity`, `objectKind`, `market_pattern`); keep rarely-filtered descriptive fields in `metadata` JSONB +(`temporalNature`, `claimType`) until a query needs them. This keeps the hot path indexable without +over-widening the table. + +--- + +## 4. Closing gap 1+2 — the seed path (the critical missing link) + +The package `@0xintuition/predicates` already *generates* `PREDICATE_SPECS`. Extend that generation to +emit a **seed artifact** the backend consumes, keyed by `slug` (stable) — not by the chain atom-hash `id`. + +**Recommended mechanism:** a generated, idempotent seed run as a migration/job: +```sql +INSERT INTO kg.predicates + (id, slug, label, description, is_symmetric, is_transitive, + inverse_predicate_id, specializes_predicate_id, polarity, object_kind, + market_pattern, metadata) +VALUES (...) -- one row per spec +ON CONFLICT (slug) DO UPDATE SET -- ⬅ UPDATE, unlike the lazy path's DO NOTHING + is_symmetric = EXCLUDED.is_symmetric, + ... , + metadata = kg.predicates.metadata || EXCLUDED.metadata, -- merge, don't clobber chain-added keys + updated_at = now(); +``` + +Key design points: +- **Slug-keyed**, so it's stable across redeploys and independent of the chain atom-hash id. +- **`DO UPDATE`** so spec changes propagate (the ingestion path's `DO NOTHING` deliberately won't). +- **JSONB merge** (`||`) so seeded metadata coexists with anything the pipeline writes. +- Resolve `inverse`/`specializes` typed keys → predicate ids/slugs **at generation time** in the package, + where the key graph is validated by `definePredicateRecord`. The backend stores resolved references. +- Ordering: seed must run **before or alongside** ingestion so lazy-upserted rows (id-keyed) and seeded + rows (slug-keyed) reconcile. Confirm the id↔slug mapping (the bytes32 atom hash ↔ `pred_follows`) is + available at seed time; if not, that mapping table is a prerequisite. + +This makes the package the single source of truth and the backend a projection of it — the right direction. + +--- + +## 5. Closing gap 3 — query optimization & performance (the payoff) + +### 5.1 Reverse-edge synthesis (`isSymmetric`, `inverse`) + +> **Prerequisite (audit A1 — blocking):** synthesis handles the *read* side only. On the *write/mint* +> side, `⟨A, partnerOf, B⟩` and `⟨B, partnerOf, A⟩` hash to two triple IDs → **two vaults**, splitting +> stake on one fact; likewise inverse pairs (`employedBy`/`employs`). Primary fix is **mint-time +> canonicalization in the package/builders** (decision record §5.2): canonical subject/object ordering +> for symmetric predicates, canonical direction for inverse pairs. The backend's job is the **backstop**: +> detect symmetric/inverse duplicates already minted (or arriving via raw protocol calls) and link them — +> same `sibling_triple_id` pattern as counter-triples, plus an explicit policy for presenting their +> aggregate stake. Without both halves, synthesis *encourages* liquidity fragmentation. + +Two strategies; choose per access pattern: + +- **Query-time rewrite (recommended default for `isSymmetric`).** To answer "edges touching X," query both + positions: `WHERE subject_id = X OR object_id = X` for symmetric predicates, presenting the reverse view + without storing it. Zero write amplification. Needs an index on `object_id` (see 5.5). +- **Materialization (recommended for hot `inverse` pairs).** A follow-up projector reads + `is_symmetric`/`inverse_predicate_id` and writes the reverse row into `kg.triples` with + **`inferred = true`**, `source = 'synthesis'`, and provenance pointing at the origin triple. The schema's + `inferred` column exists precisely for this. Pros: read-simple, single-position queries. Cons: ~2× rows + for affected predicates, and synthesized rows must be regenerated if the origin is redacted. + +Guidance: start query-time (no migration of data), materialize only predicates proven read-heavy. Never +materialize *and* double-count in reputation — tag inferred edges and exclude them from any aggregate that +already counts the origin. + +### 5.2 Transitive closure (`isTransitive`) + +- **Bounded recursive CTE at query time (recommended first).** Postgres `WITH RECURSIVE` over `kg.triples` + filtered to the transitive predicate, with a **depth cap** (e.g. 8) to bound cost on long ranking chains: + ```sql + WITH RECURSIVE reach AS ( + SELECT object_id, 1 AS depth FROM kg.triples + WHERE subject_id = $1 AND predicate_id = $2 + UNION ALL + SELECT t.object_id, reach.depth + 1 FROM kg.triples t + JOIN reach ON t.subject_id = reach.object_id + WHERE t.predicate_id = $2 AND reach.depth < 8 + ) SELECT DISTINCT object_id FROM reach; + ``` +- **Materialized closure table** (`kg.transitive_closure(predicate_id, ancestor, descendant)`) if a + containment/org query becomes hot. Higher write cost, O(1) reads. Defer until measured. + +### 5.3 Roll-up (`specializes`) + +Cheapest of all: maintain an in-memory predicate-hierarchy map (small, from `kg.predicates`) and **rewrite +the query** to expand a general predicate to its descendants: +`affiliatedWith` → `predicate_id IN (employedBy, alumniOf, memberOf, affiliatedWith)`. One indexed `IN` +scan, no recursion at the data layer (the hierarchy is shallow). This is pure OWL 2 RL and very fast. + +### 5.4 Contradiction detection (`contradicts`) + +The disagreement query: for an entity, find `(subject, object)` pairs asserted with both a predicate and +one of its `contradicts` partners. +```sql +SELECT a.subject_id, a.object_id, a.predicate_id, b.predicate_id +FROM kg.triples a +JOIN kg.predicate_contradictions c ON c.predicate_id = a.predicate_id +JOIN kg.triples b ON b.subject_id = a.subject_id + AND b.object_id = a.object_id + AND b.predicate_id = c.contradicts_id; +``` +This is also where the existing **counter-triple** scaffolding (`is_counter_triple`, `sibling_triple_id`) +connects: a `contradicts`-declared pair *is* the predicate-level rule that the counter-triple feature +expresses at the instance level. Recommend aligning the two so a counter-triple is recognized +automatically when its predicate `contradicts` the sibling's. **OWA caveat:** detect conflict only from +*explicitly asserted* contradictory edges — never infer a negative from absence (foundations doc, Part IV). + +### 5.5 Indexing recommendations + +`kg.triples` currently has only PK + the predicate FK. The query patterns above need: +```sql +CREATE INDEX ON kg.triples (subject_id, predicate_id); -- forward edges / closure seed +CREATE INDEX ON kg.triples (object_id, predicate_id); -- reverse edges / symmetric & inverse views +CREATE INDEX ON kg.triples (predicate_id); -- roll-up IN-scans, per-predicate analytics +-- partial indexes for hot filtered axes once polarity/object_kind are columns: +CREATE INDEX ON kg.triples (subject_id) WHERE inferred = false; -- exclude synthesized in base views +``` +On `kg.predicates`, index `polarity` and `object_kind` only if predicate-level filtering (not just join) +becomes common; the table is tiny (hundreds of rows), so a seq-scan there is usually fine. + +--- + +## 6. Consumer integration + +- **recommendation-service** (`recommendation-service/src/scorers/`): a new scorer (or a `prepare()` + extension) can read `kg.predicates` to (a) reweight by `polarity` — downrank entities carrying + stake-weighted negative edges; (b) follow `isTransitive` trust chains for "trusted-by-the-trusted" + social signal; (c) use `specializes` to widen interest matching. Today it reads only vaults/posts/events, + so this is additive. *(Scorer weights cited by exploration not independently verified.)* +- **API** (`api/src/triples/surreal.ts`, `routes/triples.ts`): extend the hydrated predicate object to + include the new fields, and add a `GET /api/predicates/:id` (none exists today) so clients/the frontend + get the rendering contract (`objectKind`, `polarity`, `temporalNature`) without bundling the package. +- **Validation**: `objectKind` enables an ingestion-time (or API-time) check that `object_type` matches + (`literal` ⇏ a node id; `claim` ⇒ object is a triple id, i.e. `object_type = 'triple'`). The + `object_type` column already distinguishes `'node' | 'triple'`, so `objectKind: 'claim'` maps directly. + +--- + +## 7. Risks & performance characteristics + +- **Write amplification** from materialized synthesis/closure — mitigate by defaulting to query-time and + materializing only measured-hot predicates; always tag `inferred = true` and exclude from aggregates that + count the origin. +- **Inferred-edge confidence decay (audit C3).** `kg.triples.confidence` exists for exactly this; the + policy must be explicit: an inferred edge's confidence is a **non-increasing function of its derivation + chain** — `min` of the constituent confidences (the conservative standard from the probabilistic-KG + line: PSL, Knowledge Vault) — never a copy of one constituent, never `1.0`. And inferred edges must not + feed further inference rounds across different rules without a depth bound, or materialization loops. + Skipping this paragraph is how "everything is 100% confident" graphs happen. +- **Duplicate symmetric/inverse triples** (audit A1) — see §5.1 prerequisite; mint-time canonicalization + in the package is primary, backend detect-and-link is the backstop. +- **Double-counting in reputation** — the single biggest correctness trap. Synthesized reverse edges and + rolled-up sub-property edges must not be summed alongside their origins. Make "exclude inferred" the + default in scoring aggregates. +- **Seed/ingest ordering & id↔slug mapping** — the lazy path is id-keyed and `DO NOTHING`; the seed is + slug-keyed and `DO UPDATE`. They must reconcile on the same rows. Verify the atom-hash↔slug mapping + exists at seed time. +- **Fixture-vs-migration drift** — confirm the live `kg.predicates`/`kg.triples` shapes before coding; + resolve the predicate FK discrepancy (§2). +- **OWA** — contradiction and "current state" queries must read only explicit assertions. + +--- + +## 8. Phased rollout (backend) + +1. **Confirm + migrate schema.** Verify live KG shape; add `specializes_predicate_id`, `polarity`, + `object_kind`, `market_pattern` columns, the `kg.predicate_contradictions` table, and the §5.5 indexes. + Reconcile `is_hierarchical`/`is_social`/`is_market` with the spec (derive or drop). +2. **Build the seed.** Generate the slug-keyed `DO UPDATE` seed from `PREDICATE_SPECS`; resolve typed-key + references in-package. Run ahead of ingestion. +3. **Query-time reads.** Ship symmetric/inverse rewrite, `specializes` roll-up expansion, and the bounded + transitive CTE behind the API. No data migration. Measure. +4. **Materialize the hot paths** (inferred reverse edges, closure table) only where step 3 shows pressure. +5. **Consumers.** Expose predicate metadata via API; add the `polarity`/transitive/`specializes` signals to + recommendation; wire `objectKind` validation and align `contradicts` with counter-triples. + +Steps 1–3 are additive and low-risk (dormant columns, query-time reads). The schema already meeting us +halfway is the reason this is mostly *activation*, not construction. + +--- + +## 9. Things to verify before building + +1. Live `kg.predicates` / `kg.triples` migrations vs. the test fixture (esp. whether `is_symmetric` et al. + are actually deployed). +2. The predicate FK question (`kg.triples.predicate_id` → `kg.predicates.id`?). +3. Where pre-seeded predicates (`pred_follows`, …) are currently seeded, and the atom-hash ↔ slug mapping. +4. Whether the API has committed to the KG read path (ENG-11475) before we add a predicate endpoint there + vs. SurrealDB. +5. Recommendation-service internals (scorers/weights) — exploration findings here are unverified. diff --git a/.planning/predicate-fields/README.md b/.planning/predicate-fields/README.md new file mode 100644 index 0000000..19fa420 --- /dev/null +++ b/.planning/predicate-fields/README.md @@ -0,0 +1,83 @@ +# Predicate Field Justifications + +One document per candidate field. Each makes the case for inclusion with multiple concrete +examples spanning the value axes, then assigns a **confidence score** under a fixed rubric. The +scoring is allowed to *overturn* earlier "ship" decisions — if a field can't clear the bar here, it +gets demoted. That's the point. + +## Scoring rubric (0–100) + +A field's score is a holistic judgment across four weighted dimensions: + +| Dimension | Weight | Asks | +|---|---|---| +| **Leverage** | 40% | How much does it unlock — querying, machine interpretation, frontend auto-generation, economics? Does it enable something otherwise impossible or expensive? | +| **Consumer readiness** | 25% | Does a consumer read it *today* (frontend, indexer), or only a hypothetical future engine? | +| **Simplicity / cost** | 20% | Authoring burden across 133 specs, reasoning cost at graph scale, maintenance. Cheaper is better. | +| **Non-redundancy** | 15% | Is it derivable from other fields, duplicated elsewhere, or merge-able? | + +### Bands + +| Score | Band | Action | +|---|---|---| +| 90–100 | Core | Ship now, foundational | +| 75–89 | Ship | Ship now | +| 60–74 | Soft-ship | Define now, populate opportunistically; don't block on it | +| 40–59 | Defer | Keep documented; add when a consumer appears | +| <40 | Cut | Do not add | + +## The value axes (what "leverage" is measured against) + +Every example in these docs is tagged with which axis it serves: + +- **[QUERY]** — better querying / indexer optimization / edge synthesis +- **[MACHINE]** — machine-readable interpretation, inference, consistency +- **[RENDER]** — frontend automatically generating components from the spec +- **[ECON]** — market design, reputation, staking semantics +- **[DATA]** — data quality, freshness, lifecycle + +## Summary + +| Field | Doc | Score | Band | +|---|---|---|---| +| `objectKind` | [object-kind.md](object-kind.md) | **92** | Core | +| `inverse` | [inverse.md](inverse.md) | **88** | Ship | +| `polarity` | [polarity.md](polarity.md) | **88** | Ship | +| `isSymmetric` | [is-symmetric.md](is-symmetric.md) | **85** | Ship | +| `temporalNature` | [temporal-nature.md](temporal-nature.md) | **85** | Ship | +| `literalType` *(audit)* | [literal-type.md](literal-type.md) | **85** | Ship | +| `marketPattern` | [market-pattern.md](market-pattern.md) | **84** | Ship (grandfathered) | +| `contradicts` | [contradicts.md](contradicts.md) | **82** | Ship | +| `isTransitive` | [is-transitive.md](is-transitive.md) | **80** | Ship | +| `specializes` (array) | [specializes.md](specializes.md) | **80** | Ship | +| `supersededBy` *(audit)* | [superseded-by.md](superseded-by.md) | **70** | Soft-ship | +| `claimType` | [claim-type.md](claim-type.md) | **64** | Soft-ship | +| `isAsymmetric` | [is-asymmetric.md](is-asymmetric.md) | **54** | **Defer** (demoted) | +| `isInverseFunctional` | [deferred-and-cut.md](deferred-and-cut.md) | 50 | Defer | +| `isFunctional` | [deferred-and-cut.md](deferred-and-cut.md) | 44 | Defer | +| `isReflexive`/`isIrreflexive` | [deferred-and-cut.md](deferred-and-cut.md) | 34 | Cut | +| `equivalentTo` | [deferred-and-cut.md](deferred-and-cut.md) | 32 | Cut | +| `verifiability` | [deferred-and-cut.md](deferred-and-cut.md) | 30 | Cut | +| property chains | [deferred-and-cut.md](deferred-and-cut.md) | 26 | Cut | +| `isHierarchical` | [deferred-and-cut.md](deferred-and-cut.md) | 18 | Cut (redundant) | + +### What changed from the decision record + +- **`isAsymmetric` demoted to Defer (54).** Under scrutiny its only consumer is a validation engine + that doesn't exist; its "don't mirror" signal is already covered by the absence of `isSymmetric`. + The shipped algebraic set tightens to **`isSymmetric` + `isTransitive`** — both with a consumer today. +- **`claimType` lands at Soft-ship (64),** not full ship — define it, populate where obvious, but no + consumer branches on it yet. + +### What the audit pass added (`../predicate-spec-audit.md`, 2026-07-01) + +- **`literalType` (85, Ship)** — typed literals complete the rendering contract; `objectKind: 'literal'` + alone can't distinguish image/link/date/number/text. +- **`supersededBy` (70, Soft-ship)** — deprecation forwarding (Wikidata "replaced by"). +- **`specializes` widened to `readonly PredicateKey[]`** — hierarchies are DAGs, not trees. Score unchanged. +- Blocking process findings (mint-time canonicalization for symmetric/inverse; pair-level `contradicts` + definition) and the complete 14-rule validation set live in the decision record §5.1–5.3. + +**Final ship list (10 new/active semantic fields):** `objectKind`, `literalType`, `inverse`, `polarity`, +`isSymmetric`, `temporalNature`, `contradicts`, `isTransitive`, `specializes` + grandfathered +`marketPattern`; plus `claimType` and `supersededBy` as soft-ship. diff --git a/.planning/predicate-fields/claim-type.md b/.planning/predicate-fields/claim-type.md new file mode 100644 index 0000000..dcf912c --- /dev/null +++ b/.planning/predicate-fields/claim-type.md @@ -0,0 +1,67 @@ +# `claimType` + +**Type:** `'factual' | 'evaluative'` (`normative` dropped) +**Confidence: 64 / 100 — Soft-ship. Define now, populate opportunistically, don't block on it.** + +> Verdict: real and orthogonal value (fact vs opinion changes market and UI semantics), but no consumer +> *branches* on it yet. Cheap enough to define and populate where obvious; not worth a curation campaign +> until a market or badge actually reads it. + +## The problem it solves + +A claim that can be checked against the world (`manufacturer`) is a different *kind* of thing from a +perpetual opinion (`betterThan`). `claimType` records that distinction: + +- `factual` — verifiable in principle: `manufacturer`, `authoredBy`, `parentOrganization`, + `linkedAccount`, `containedInPlace`, `founded`. +- `evaluative` — subjective judgment: `betterThan`, `like`, `bullishOn`, `trust`, `recommend`, + `worseThan`. + +(`normative` — "ought" claims like `compliantWith` — was cut: too few predicates, no consumer.) + +## Examples across the value axes + +1. **[ECON] Market resolution semantics.** A `factual` claim can in principle *resolve* (an oracle or + consensus settles "is Foxconn the manufacturer?") and pay out. An `evaluative` claim is perpetually + contestable — there's no ground truth to "Apple is better than Samsung," so its market stays open as + an opinion poll. The economic layer *would* branch on this — but doesn't yet. This is the strongest + justification and also the reason the field is soft-ship: the consumer is real but future. + +2. **[RENDER] "Verifiable" vs "Opinion" badge.** The UI can badge factual claims as checkable and + evaluative ones as opinions, setting user expectations about whether a claim can ever be "settled." + This consumer *could* ship today but is low-stakes. + +3. **[QUERY] Separate the knowledge graph from the opinion graph.** "Show only factual claims about this + entity" gives a sourcing/encyclopedia view; "show opinions" gives a sentiment view. Useful slice, + not yet requested. + +## Honest case against (why not full ship) + +- **No consumer branches on it today.** Unlike `objectKind` (frontend reads it now) or `polarity` + (reputation reads it now), nothing currently changes behavior based on `claimType`. +- **Partial correlation with other fields.** `evaluative` claims usually have a `polarity` and often a + `comparative` `marketPattern`; `factual` claims usually have `polarity` absent. It's not *derivable* + (`blocked` is factual yet negative-polarity), but the correlation means it carries less *independent* + information than the top-tier fields. + +## Resolution: soft-ship + +Add the field to the type. Populate it where the answer is obvious and free (during the other backfills). +Do **not** run a dedicated curation pass or block the rollout on it. Promote to full ship the moment a +market mechanic or a shipped badge reads it. + +## Cost / complexity + +Low to define; the temptation-to-overthink (where exactly is the fact/opinion line?) is the real cost, +which soft-ship avoids by not demanding completeness. + +## Pruning check + +- *Derivable?* No, but correlated — hence the docked non-redundancy score. +- *Duplicated?* Overlaps conceptually with `marketPattern: comparative` and with `polarity`; distinct but + related. +- *Mergeable?* Considered merging into `marketPattern`; rejected — "how it trades" ≠ "what kind of claim." + +## Confidence breakdown + +Leverage 28/40 · Consumer readiness 12/25 · Simplicity 17/20 · Non-redundancy 7/15 → **64**. diff --git a/.planning/predicate-fields/contradicts.md b/.planning/predicate-fields/contradicts.md new file mode 100644 index 0000000..5a2e60b --- /dev/null +++ b/.planning/predicate-fields/contradicts.md @@ -0,0 +1,88 @@ +# `contradicts` + +**Type:** `readonly PredicateKey[]` (declared symmetrically on both predicates) +**Confidence: 82 / 100 — Ship.** + +> Verdict: the most Intuition-*native* reasoning field. A belief/trust graph is valuable precisely because +> it can represent and price disagreement — and `contradicts` is the formal hook for it. Descends from +> OWL 2 `propertyDisjointWith` and WordNet antonymy. + +## The problem it solves + +Some predicates cannot both hold for the same subject/object pair without incoherence. `contradicts` +declares those disjoint pairs so the system can *detect* conflict instead of silently storing both: + +- `trust` ⊥ `distrust` +- `bullishOn` ⊥ `bearishOn` +- `support` ⊥ `oppose` +- `agreeWith` ⊥ `disagreeWith` +- `endorse` ⊥ `reported` (softer; see "strength" below) + +## Examples across the value axes + +1. **[ECON] Disagreement markets — the differentiator.** The triples `⟨DAO-X, trust, Bob⟩` and + `⟨DAO-X, distrust, Bob⟩` — **same subject, same object** — both accrue stake. Because the predicates + are declared `contradicts`, the system recognizes a *priced contradiction* and routes both into a + single conflict market rather than two unrelated claims. No classical fact-graph (DBpedia, Wikidata, + Google KG) was built to model this; it is the cutting-edge capability the whole effort is aiming at. + + > **Precision (audit A2): pair-level only.** "Alice asserts trust Bob, Carol asserts distrust Bob" + > is **not** a contradiction — the subjects differ; that's ordinary disagreement, and the aggregate + > ("how contested is Bob?") is served by `polarity` (signed sum over edges into Bob) plus + > counter-triples. `contradicts` fires only on the same ⟨subject, object⟩ pair with disjoint + > predicates, exactly as OWL `propertyDisjointWith` defines it — and that's what the backend's + > conflict-detection join keys on. Conflating the two layers produces a conflict query that matches + > everything or nothing. + +2. **[RENDER] Conflict surfacing in the UI.** An entity page can render a "Contested" badge and show the + opposing camps side by side when contradictory predicates both have stake. The frontend finds the + conflict by reading `contradicts`, not by hardcoding which predicate pairs oppose. + +3. **[MACHINE] Consistency checking.** Under the Open World Assumption you can *never* infer `¬trust` from + the absence of a trust edge (foundations doc, Part IV) — so contradiction must come from an explicit + disjointness axiom. `contradicts` *is* that axiom. It's the only correct way to detect conflict in an + open-world graph. + +4. **[QUERY] Find the controversies.** "Show the most contested entities this week" = rank entities by + stake-weighted presence of `contradicts` pairs. A first-class signal for discovery/feeds. + +## Hard vs soft contradiction (a real design nuance) + +`trust`/`distrust` is a *hard* logical contradiction. `skepticalOf`/`trust` is *tension*, not strict +contradiction — one can be skeptical yet still net-trusting. The decision record flags whether we need a +separate `tension` relation. Current lean: ship `contradicts` for hard pairs now; model soft tension later +if a consumer needs it. Don't overload one field with two strengths. + +## What breaks without it + +The graph can hold `trust Bob` and `distrust Bob` with no idea they conflict. The disagreement-market +thesis — arguably Intuition's core value proposition — has no data substrate. Conflict detection devolves +to per-pair hardcoding in each consumer. + +## Cost / complexity + +Moderate — it needs **human curation** (which pairs truly oppose) and must be declared symmetrically, which +the derivation layer enforces (`A.contradicts ∋ B ⟺ B.contradicts ∋ A`). Forward-chainable / cheap to +check. The curation set is small (sentiment pairs). + +Two additional gate rules from the audit (decision record §5.1): a predicate may not contradict its own +`specializes` ancestor/descendant (`P ⊑ Q` ∧ `P ⊥ Q` makes P unsatisfiable), and the contradiction set is +**closed down the hierarchy at build time** (`vouchFor ⊑ trust` ∧ `trust ⊥ distrust` ⟹ `vouchFor ⊥ +distrust`) — otherwise conflict detection silently misses every sub-predicate pair. + +**What it cannot express (audit D3):** converse-pair incoherence — `⟨A, betterThan, B⟩` alongside +`⟨B, betterThan, A⟩` is a *reversed pair with the same predicate*, i.e. asymmetry violation, not +disjointness. That has no shipped mechanism while `isAsymmetric` is deferred; we accept the gap knowingly. + +## Pruning check + +- *Derivable?* Partially correlated with `polarity` (opposite polarity is a *hint*) but not derivable — + `blocked` (negative) doesn't contradict `manufacturer` (absent); only specific semantic pairs oppose. +- *Duplicated?* No. +- *Mergeable?* No. + +## Confidence breakdown + +Leverage 36/40 · Consumer readiness 18/25 · Simplicity 15/20 · Non-redundancy 13/15 → **82**. +(Deductions: curation cost, and the consuming market layer is emerging rather than shipped. The +strategic upside keeps it firmly in "ship.") diff --git a/.planning/predicate-fields/deferred-and-cut.md b/.planning/predicate-fields/deferred-and-cut.md new file mode 100644 index 0000000..503244a --- /dev/null +++ b/.planning/predicate-fields/deferred-and-cut.md @@ -0,0 +1,116 @@ +# Deferred & Cut Fields + +These candidates did not clear the bar. Documented here so the reasoning is preserved and so each can be +promoted later with a clear trigger. Each carries its confidence score and the specific reason it's held. + +--- + +## `isInverseFunctional` — 50 / 100 — DEFER (strategically flagged) + +**Asserts:** the object uniquely identifies the subject — `P(a,c) ∧ P(b,c) ⟹ a = b`. The object is a *key*. + +**Best case:** `linkedAccount` — whoever links a given social account is the same person. This is the +Semantic Web's entity-resolution mechanism (an IFP is a database key without the join), and for a +*permissionless* graph where the same real entity gets minted many times, dedup is strategically large. + +**Why deferred, not shipped:** +- **No consumer.** There is no dedup/identity-resolution engine reading it today. The value is entirely + prospective. +- **Dangerous without UNA.** Under no-Unique-Name semantics, a mis-asserted IFP *infers identity* and can + collapse distinct entities ("smushing"). Shipping it before there's a careful consumer is a footgun on a + permissionless network. + +**Promotion trigger:** when an identity-resolution / dedup feature is scoped. *Consider pulling it forward +to start collecting the data early* (decision-record open question #3) — but only the data, with no +auto-merge, until the consumer is built and safety-reviewed. + +--- + +## `isFunctional` — 44 / 100 — DEFER + +**Asserts:** each subject has at most one object — `P(a,b) ∧ P(a,c) ⟹ b = c`. + +**Best case:** `manufacturer`, `primaryImageOfPage`, `parentOrganization` (arguably). Enables "you asserted +two manufacturers; one is wrong" conflict detection. + +**Why deferred:** the conflict-detection consumer is the same nonexistent validation engine that demotes +`isAsymmetric`. Cardinality is also genuinely ambiguous for many predicates (does a product have exactly +one manufacturer? co-manufacturing exists), so the authoring burden comes with real edge-case debate for +limited present payoff. + +**Promotion trigger:** write-time validation engine, shipped together with the rest of the validation +fields. + +--- + +## `isReflexive` / `isIrreflexive` — 34 / 100 — CUT + +**Asserts:** `P(a,a)` always / never. + +**Why cut:** almost nothing creates self-loops, so the validation value is near-zero in practice; +`irreflexive` is largely *derivable* (`asymmetric ⟹ irreflexive`); and `reflexive` applies to a tiny set +(`sameAs`). High specification overhead for a corner case. If self-loop validation ever matters, it can be +a single special-cased rule rather than a field on all 133 specs. + +--- + +## `equivalentTo` (predicate-level alias) — 32 / 100 — CUT + +**Asserts:** two predicates are interchangeable (`createdBy ≡ authoredBy`). + +**Why cut:** we *deliberately* keep near-synonyms distinct (they carry different connotation and different +markets), so there are few true equivalences to assert. And under no-UNA, predicate aliasing risks +collapsing distinct claims and destabilizing atom-ids. The cost/risk outweighs a benefit we mostly don't +want. If genuine duplicates emerge, handle them case-by-case via deprecation (`status: 'deprecated'` + +`replacedBy`), not a blanket equivalence axiom. + +--- + +## `verifiability` (`onChain` / `offChainVerifiable` / `unverifiable`) — 30 / 100 — CUT + +**Asserts:** how a claim could be checked. + +**Why cut:** strongly correlated with `claimType` (factual things are the verifiable ones), so it's a +near-duplicate second axis. It adds three-way authoring overhead for information `claimType` already +approximates. Revisit only if an oracle/resolution system needs a *finer* verifiability signal than +factual-vs-evaluative — and even then, prefer extending `claimType` over a parallel field. + +--- + +## property chains (`P ∘ Q ⊑ R`) — 26 / 100 — CUT (for now) + +**Asserts:** composed paths imply a relation (`parentOf ∘ parentOf ⊑ grandparentOf`; +`locatedIn ∘ containedInPlace ⊑ locatedIn`). + +**Why cut:** the most expressive *and* most expensive axiom in OWL 2 (SROIQ admits it only under an +acyclicity restriction to stay decidable). No consumer, and it sits at the costly end of the +expressivity/tractability frontier we explicitly chose to stay inside (OWL 2 RL). Pure future research. + +**Promotion trigger:** a dedicated reasoning module (`@0xintuition/reasoning`) with a compute budget for +chain materialization. + +--- + +## `isHierarchical` — 18 / 100 — CUT (redundant) + +**Asserts (today):** the predicate forms a containment/subsumption tree. + +**Why cut:** fully **derivable** from `isTransitive + (isAsymmetric or a defined `inverse`)`. It encodes no +information the other fields don't already carry; it's a convenience label that invites drift (a spec could +declare `isHierarchical: true` while *not* being transitive, producing an incoherent record). Replace any +current use with the derived combination. If a tree-UI hint is genuinely wanted, compute it in +`definePredicateRecord` as a derived getter — never an authored field. + +--- + +## Summary of triggers + +| Field | Score | Promote when… | +|---|---|---| +| `isInverseFunctional` | 50 | dedup/identity-resolution feature is scoped (maybe collect data earlier) | +| `isFunctional` | 44 | write-time validation engine ships | +| `isReflexive`/`isIrreflexive` | 34 | (don't) — handle self-loops as a one-off rule if ever needed | +| `equivalentTo` | 32 | (don't) — use deprecation flow instead | +| `verifiability` | 30 | only if oracle resolution needs finer signal than `claimType` | +| property chains | 26 | a reasoning module with a compute budget exists | +| `isHierarchical` | 18 | never — derive it | diff --git a/.planning/predicate-fields/inverse.md b/.planning/predicate-fields/inverse.md new file mode 100644 index 0000000..e507b9f --- /dev/null +++ b/.planning/predicate-fields/inverse.md @@ -0,0 +1,78 @@ +# `inverse` + +**Type:** `PredicateKey` (typed reference to another predicate) +**Confidence: 88 / 100 — Ship.** + +> Verdict: lets the indexer serve both directions of a relationship from a single stored edge, using the +> *correct* predicate for the reverse direction. Direct descendant of `owl:inverseOf`. A consumer (the +> indexer) reads it today. + +## The problem it solves + +Most directional relationships are interesting from both ends, but the reverse end is usually a +*different* predicate. `inverse` declares that pairing so the reverse edge never has to be minted +separately: + +- `parentOrganization` ⇄ `subOrganization` +- `follow` ⇄ `followedBy` +- `predecessorOf` ⇄ `successorOf` +- `containsPlace` ⇄ `containedInPlace` +- `reviewed` ⇄ `reviewedBy` + +## Examples across the value axes + +1. **[QUERY] One write, two views.** Kames asserts `BigCo parentOrganization Acme`. Acme's profile shows + "Parent: BigCo"; BigCo's profile lists "Subsidiaries: Acme, …" — both synthesized by the indexer from + one atom via `inverse`. We don't ask users to also mint `Acme subOrganization BigCo`, and we don't + store it. + +2. **[RENDER] The reverse section labels itself.** A profile page renders an "incoming edges" section by + looking up each predicate's `inverse` and using *its* display name. Without the field, the UI either + shows awkward passive phrasings ("is parent-organization-of by BigCo") or hardcodes reverse labels. + +3. **[MACHINE] Closes the relationship under inversion.** A reasoner that knows `inverse` can answer + "is Acme a subsidiary of BigCo?" from a `parentOrganization` assertion. This is one of the four + relational patterns (symmetry, antisymmetry, **inversion**, composition) that both OWL and the RotatE + embedding line independently identified as load-bearing — strong evidence it's not optional fluff. + +4. **[DATA] Consistency gate.** `definePredicateRecord` cross-checks that `parentOrganization.inverse === + 'subOrganization'` *and* `subOrganization.inverse === 'parentOrganization'`, and that the pair mirror + each other's algebraic properties (both transitive, etc.). A one-sided inverse fails the build. + +## Canonical direction (audit A1 — ships with the field) + +Under content-addressed IDs, an inverse pair is a duplicate-fact hazard: `⟨Alice, employedBy, Acme⟩` and +`⟨Acme, employs, Alice⟩` are one fact, two mintable triples, **two vaults** — split stake. Wikidata hit the +analogous problem and needed permanent bot-sync for inverse pairs; their considered fix was canonical +directions. Ours (decision record §5.2): `definePredicateRecord` derives a **canonical direction** per +pair (deterministic rule, e.g. lexicographically smaller key); builders normalize assertions in the +non-canonical direction to the canonical triple. The non-canonical predicate stays fully usable in UI and +queries — it's a *view* over the canonical edge, not a second fact. The indexer links stray duplicates as +a backstop. + +## Why typed key, not string + +Today the codebase has `inversePredicate: 'sub organization'` — a *display name*. Display names get +edited, translated, and drift; the link silently rots. A typed `PredicateKey` is validatable at build +time and lets the consistency gate above exist at all. This is the one genuine refactor in the field set. + +## What breaks without it + +Reverse-direction navigation either forces double-minting (more atoms, more user friction, divergent +data) or relies on hardcoded reverse-label maps in the frontend. Inversion-based inference is impossible. + +## Cost / complexity + +Low. Many predicates already imply their inverse; curating the pairs is a bounded one-time pass. The +string→key migration is mechanical. Reasoning cost is trivial (a lookup). + +## Pruning check + +- *Derivable?* No — the pairing is semantic, declared per predicate. +- *Duplicated?* Partially overlaps `isSymmetric` conceptually (a symmetric predicate is its own inverse), + which we handle by deriving `inverse = self` when `isSymmetric` is set, so authors never write both. +- *Mergeable?* No. + +## Confidence breakdown + +Leverage 35/40 · Consumer readiness 22/25 · Simplicity 18/20 · Non-redundancy 13/15 → **88**. diff --git a/.planning/predicate-fields/is-asymmetric.md b/.planning/predicate-fields/is-asymmetric.md new file mode 100644 index 0000000..a7d3271 --- /dev/null +++ b/.planning/predicate-fields/is-asymmetric.md @@ -0,0 +1,61 @@ +# `isAsymmetric` + +**Type:** `boolean` (default false) +**Confidence: 54 / 100 — DEFER. Demoted from the decision record's "ship."** + +> Verdict: scrutiny didn't hold up the ship call. `isAsymmetric`'s only genuine consumer is a validation +> engine that doesn't exist, and its "don't mirror the edge" signal is already implied by the *absence* of +> `isSymmetric`. Keep it documented; add it the day write-time validation lands. + +## What it would assert + +`R(a,b) ⟹ ¬R(b,a)` — the relationship cannot hold in both directions: + +- `parentOrganization`, `betterThan`, `founder`, `predecessorOf`, `rankedAbove`. + +## The case that was made for it + +1. **[MACHINE] Validation.** Reject `A betterThan B` *and* `B betterThan A` as incoherent. +2. **[QUERY] "Don't synthesize the reverse as the same predicate."** +3. **[MACHINE] Implies irreflexivity** (`A betterThan A` is invalid). + +## Why it doesn't clear the bar + +1. **Its query/indexer value is already covered.** The indexer synthesizes a same-predicate reverse edge + *only* when `isSymmetric` is true. So "don't mirror" is the **default** — the absence of `isSymmetric` + already says it. `isAsymmetric` adds no new instruction to the one consumer that exists today (the + indexer). And where the reverse direction *is* meaningful, `inverse` already names it. + +2. **Its only unique value is write-time validation, which has no consumer.** Rejecting contradictory + asymmetric assertions requires a validation engine the system doesn't have. Per the project's own + pruning principle — *prefer fields with a consumer that exists today* — this is exactly the kind of + field to defer until that engine appears. + +3. **The implication it carries is derivable.** `asymmetric ⟹ irreflexive` is a logical entailment, not + independent information; a reasoner derives it. + +4. **Consistency with the rest of the prune.** We deferred the functional family and reflexivity for the + same reason (validation-only, no consumer). Shipping `isAsymmetric` while deferring those would be + incoherent — it sits in the same bucket. + +## The gap the deferral leaves open (audit D3) + +Be explicit about what we're living without: converse-pair incoherence — `⟨A, betterThan, B⟩` alongside +`⟨B, betterThan, A⟩` — has **no shipped mechanism**. `contradicts` cannot express it (it's a reversed +pair with the *same* predicate, not a same-pair with disjoint predicates). The decision record's P3 now +states this gap rather than implying `contradicts` covers it. Comparative-predicate coherence is the +concrete scenario that graduates this field. + +## When to promote + +Pull it in (likely alongside `isFunctional`/reflexivity) the moment write-time edge validation or a +consistency-checking pass becomes a real consumer. At that point all the "reject incoherent edges" fields +graduate together as a coherent validation layer. Note that the mint-time canonicalization machinery for +symmetric/inverse predicates (audit A1, decision record §5.2) builds direction-aware handling into the +builders — once that exists, activating `isAsymmetric` becomes materially cheaper. + +## Confidence breakdown + +Leverage 22/40 · Consumer readiness 8/25 · Simplicity 18/20 · Non-redundancy 6/15 → **54**. +(The deductions are consumer-readiness and non-redundancy: no live consumer, and the live signal is +already implied by `¬isSymmetric` + `inverse`.) diff --git a/.planning/predicate-fields/is-symmetric.md b/.planning/predicate-fields/is-symmetric.md new file mode 100644 index 0000000..d6dc2d3 --- /dev/null +++ b/.planning/predicate-fields/is-symmetric.md @@ -0,0 +1,68 @@ +# `isSymmetric` + +**Type:** `boolean` (default false) +**Confidence: 85 / 100 — Ship.** + +> Verdict: store one edge, serve both directions, when the reverse is the *same* predicate. Cheap, +> indexer reads it today, one of the four canonical relational patterns. + +## The problem it solves + +Some relationships are mutual by definition — asserting one direction logically asserts the other, *with +the same predicate*. `isSymmetric` declares this so the reverse edge is synthesized, never minted twice: + +- `affiliatedWith` — if Alice is affiliated with Acme, Acme is affiliated with Alice. +- `partnerOf`, `competeWith`, `compatibleWith`, `alternativeTo`, `equivalentTo`, `sameAs`. + +(Contrast `inverse`, which handles the case where the reverse is a *different* predicate.) + +## Examples across the value axes + +1. **[QUERY] Half the writes, symmetric reads.** `Acme partnerOf BigCo` is stored once; the indexer + answers "who are BigCo's partners?" by matching the edge in either position. For a permissionless graph + paying for storage, not double-minting every mutual relationship is a real saving. + +2. **[RENDER] Both profiles populate from one assertion.** Acme's page and BigCo's page both show the + partnership without the user creating two atoms or the UI guessing direction. + +3. **[MACHINE] Canonical relational pattern.** Symmetry is one of the four patterns (symmetry, + antisymmetry, inversion, composition) that OWL formalized and the RotatE/ComplEx embedding line + independently rediscovered as essential. A graph that can't represent symmetry mis-models a large class + of relations. Cheap to assert, foundational to get right. + +4. **[DATA] Consistency.** The derivation layer sets `inverse = self` for symmetric predicates and rejects + a spec that declares both `isSymmetric` and a *different* `inverse` — a contradiction. + +## The Intuition-specific requirement: mint-time canonicalization (audit A1 — blocking) + +OWL's world has no cost to a duplicate edge. Ours does: every triple has a **deterministic ID and its own +vault**, so `⟨Acme, partnerOf, BigCo⟩` and `⟨BigCo, partnerOf, Acme⟩` — the *same fact* — hash to two +triple IDs and **two markets**, splitting stake. The flag alone doesn't prevent this; nothing stops two +users independently minting both directions. + +Therefore `isSymmetric` ships **with** canonicalization (decision record §5.2): builders in `primitives` +canonically order subject/object (by atom ID) before computing the triple ID, so both user intents resolve +to one triple and one market. The indexer detects-and-links pre-existing duplicates as a backstop +(`sibling_triple_id` pattern). Without this, symmetric synthesis *encourages* liquidity fragmentation. + +## What breaks without it + +Either users mint two atoms per mutual relationship (friction + storage + drift between the two — and +under content-addressed IDs, **split stake across two markets for one fact**), or the indexer can't safely +answer reverse queries because it doesn't know the relation is mutual. + +## Cost / complexity + +Minimal — a boolean, obviously true/false per predicate, trivially forward-chainable (OWL 2 RL). + +## Pruning check + +- *Derivable?* No — mutuality is semantic. +- *Duplicated?* Interacts with `inverse` (symmetric ⟹ self-inverse) but is the cleaner way to express the + common "same predicate both ways" case; we derive the inverse rather than make authors write it. +- *Mergeable?* Could in theory be modeled as `inverse: `, but a dedicated boolean is far clearer to + authors and to the serializer (`owl:SymmetricProperty` is itself a dedicated type, not `inverseOf self`). + +## Confidence breakdown + +Leverage 33/40 · Consumer readiness 22/25 · Simplicity 20/20 · Non-redundancy 10/15 → **85**. diff --git a/.planning/predicate-fields/is-transitive.md b/.planning/predicate-fields/is-transitive.md new file mode 100644 index 0000000..7c9d429 --- /dev/null +++ b/.planning/predicate-fields/is-transitive.md @@ -0,0 +1,86 @@ +# `isTransitive` + +**Type:** `boolean` (default false) +**Confidence: 80 / 100 — Ship.** + +> Verdict: enables reachability/closure queries (containment trees, org hierarchies, rankings) that are +> otherwise expensive or impossible to express. Indexer/query layer reads it. One of the four canonical +> relational patterns (composition). + +## The problem it solves + +For some predicates, a chain implies the endpoints relate directly: `R(a,b) ∧ R(b,c) ⟹ R(a,c)`. +`isTransitive` declares which, so the query layer can compute transitive closure: + +- `containedInPlace` / `containsPlace` — a neighborhood in a city in a country. +- `parentOrganization` / `subOrganization` — corporate hierarchies. +- `rankedAbove`, `betterThan`, `outperform` — ordering chains. +- `dependOn`, `partOfSeries`, `precededBy` / `successorOf`. + +## Examples across the value axes + +1. **[QUERY] "Everything within X" without enumerating the tree.** "All places in California" follows + `containedInPlace` transitively: county → city → district → venue. The indexer knows it may close the + relation because the predicate is marked transitive. Without the flag, this is either a hardcoded + special case per predicate or a query the system can't safely answer. + +2. **[QUERY] Org-chart rollups.** "Is Acme ultimately controlled by BigCo?" walks + `parentOrganization` through intermediate holding companies. Reputation/ownership analyses depend on + this closure. + +3. **[RENDER] Tree and breadcrumb UI.** A location breadcrumb ("Venue ▸ District ▸ City ▸ Country") or an + org tree component is generated by following a transitive predicate. The component asks "is this + predicate transitive?" to decide whether to recurse. + +4. **[MACHINE] Composition pattern.** Transitivity is the "composition" member of the four relational + patterns OWL and RotatE both treat as essential. Correctly modeling it is table stakes for a + reasoning-capable graph. + +## A caution carried from the research + +OWL DL forbids combining transitivity with cardinality restrictions on the same property (it breaks +decidability), and transitive closure is more expensive than the other RL-core inferences. We ship the +flag, but the *computation* of closure is an indexer/query-time decision (materialize vs. compute on +demand), and we should bound depth for ranking chains that could be long. This is a known, manageable cost. + +## ⚠️ Sentiment is never transitive (audit C1 — enforced as gate rule 10) + +The single most tempting authoring mistake is "if Alice trusts Bob and Bob trusts Carol, Alice trusts +Carol." The canonical result — Guha et al. (2004), *Propagation of Trust and Distrust* — debunks it: +trust decays sharply with path length, and **distrust does not propagate transitively at all** (one-step +only; two distrust hops arguably flip sign, per structural balance). Marking a sentiment predicate +transitive lets the indexer synthesize edges the theory says are false, and closure over a social graph is +also a combinatorial explosion. So `definePredicateRecord` **errors on `polarity` present ∧ +`isTransitive`**. Transitivity belongs to structural containment (`containedInPlace`, `partOfSeries`, +`parentOrganization`, `dependOn`) and `sameAs` — never to `trust`, `endorse`, `distrust`, or kin. + +Related caution on comparatives: per-attester chains of `betterThan`/`rankedAbove` are transitive, but the +**community aggregate is not** — pairwise majority preferences famously cycle (Condorcet). Closure over +comparative predicates should operate within one attester's assertions, not across the aggregate. + +Also note (gate rule 11): transitivity is **not inherited** via `specializes` — a sub-property of a +transitive property is not itself transitive (RDFS/OWL semantics). The indexer must never expand closure +over the sub-property hierarchy. + +## What breaks without it + +Closure/reachability queries become per-predicate special cases or are simply unavailable; tree/breadcrumb +UI must hardcode which predicates nest. The graph can't answer "indirectly related via X" generically. + +## Cost / complexity + +Boolean, obvious to assign. The flag is cheap; the closure computation it enables is moderate cost and is +the indexer's call to make (materialized path index, bounded depth, etc.). + +## Pruning check + +- *Derivable?* No. +- *Duplicated?* No — distinct from `inverse`/`isSymmetric` (a predicate can be transitive and + asymmetric, e.g. `parentOrganization`). +- *Mergeable?* No. + +## Confidence breakdown + +Leverage 33/40 · Consumer readiness 20/25 · Simplicity 16/20 · Non-redundancy 11/15 → **80**. +(Deduction: closure carries real compute cost, so the value is gated on the indexer choosing to +materialize it; the flag itself is unambiguous and cheap.) diff --git a/.planning/predicate-fields/literal-type.md b/.planning/predicate-fields/literal-type.md new file mode 100644 index 0000000..b933d46 --- /dev/null +++ b/.planning/predicate-fields/literal-type.md @@ -0,0 +1,62 @@ +# `literalType` + +**Type:** `'url' | 'image' | 'date' | 'number' | 'text'` — only meaningful when `objectKind === 'literal'` +**Confidence: 85 / 100 — Ship.** *(Added by the audit pass, `predicate-spec-audit.md` A3.)* + +> Verdict: the missing half of the rendering contract. `objectKind: 'literal'` says the object is a value, +> but not *what kind* — and the literal subset is exactly where rendering varies most. RDF solved this with +> typed literals (`xsd:anyURI`, `xsd:date`, …); this is our pragmatic equivalent. + +## The problem it solves + +`objectKind`'s flagship promise is "the frontend renders any edge with zero per-predicate code." But its +own example betrays a gap: *"render `imgUrl` as an ``, `url` as an ``"* — that switch is back on +the **predicate key**. Knowing "it's a literal" doesn't distinguish image / link / date / number / prose. +Without a datatype, the per-predicate `switch` the field set promises to delete survives for literals: + +- `imgUrl` → `image` → `` +- `url`, `hasSource` → `url` → `` +- `foundedOn`, `releaseDate` → `date` → formatted date, locale-aware +- `employeeCount`, `price` → `number` → formatted numeral +- `hasDescription`, `bio` → `text` → prose block + +## Examples across the value axes + +1. **[RENDER] Completes the generic `` component.** The `literal` branch switches on + `literalType` instead of predicate key. New literal predicates render correctly with zero frontend + changes — the actual promise of the rendering contract, now kept for the hardest subset. + +2. **[DATA] Value validation at write time.** `literalType: 'url'` ⟹ validate URL shape; + `'date'` ⟹ parseable date; `'number'` ⟹ numeric. Catchable at authoring/API time the moment the field + exists — no engine needed, it's a format check, not inference. + +3. **[MACHINE] Serializes to standard XSD datatypes.** `url → xsd:anyURI`, `date → xsd:date`, + `number → xsd:decimal` — third parties get typed literals exactly as RDF defines them. + +4. **[QUERY] Typed filtering/sorting.** "Sort by release date," "filter numeric ranges" require the indexer + to know which literal edges are dates/numbers before it can index them as such. + +## Why not lean on `classifications` / schema.org property types instead? + +That re-introduces a cross-package lookup in the hot rendering path — the coupling the rendering-contract +argument was built to avoid. The literal predicate set is small; the annotation is mechanical. + +## Cost / complexity + +Trivial. Only populated on literal predicates (a small set), values are obvious, validated by rule 12 +(`literalType` present ⟹ `objectKind === 'literal'`) in `definePredicateRecord`. + +## Pruning check + +- *Derivable?* Sometimes sniffable from stored values, but that's runtime guessing — the point is a + declared contract. Not derivable from other spec fields. +- *Duplicated?* No. `objectKind` is the coarse axis; this refines exactly one of its values. +- *Mergeable?* Could be folded into `objectKind` as a 7-value enum (`entity | claim | url | image | …`), + but that muddles the coarse entity/claim/literal distinction consumers branch on first. Two fields, one + subordinate, is cleaner. + +## Confidence breakdown + +Leverage 34/40 · Consumer readiness 23/25 · Simplicity 19/20 · Non-redundancy 9/15 → **85**. +(Deduction: partial overlap with what value-sniffing or schema.org types could approximate; the declared +contract still wins for the hot path.) diff --git a/.planning/predicate-fields/market-pattern.md b/.planning/predicate-fields/market-pattern.md new file mode 100644 index 0000000..5dca857 --- /dev/null +++ b/.planning/predicate-fields/market-pattern.md @@ -0,0 +1,64 @@ +# `marketPattern` + +**Type:** `'depositional' | 'attributive' | 'comparative'` +**Confidence: 84 / 100 — Ship (grandfathered; needs a semantics clarification of its own).** + +> Verdict: the one economic field with no analog in 30 years of classical knowledge-graph research — it's +> Intuition-native and already in production. It stays. The caveat is that its three values deserve a +> dedicated semantics doc, because unlike the OWL-derived fields, there's no external literature to anchor +> their meaning. + +## The problem it solves + +Intuition predicates aren't just relations — they're *markets*. `marketPattern` classifies how a +predicate behaves economically (bonding-curve / staking dynamics), which no other field captures: + +- `depositional` — e.g. `affiliatedWith`, `alumniOf` (association-style claims). +- `attributive` — e.g. `actor`, `parentOrganization`, `containedInPlace` (attaching an attribute/role). +- `comparative` — e.g. `betterThan`, `equivalentTo`, `outperform`, `rankedAbove` (relative judgments). + +## Examples across the value axes + +1. **[ECON] Market mechanics per predicate.** The staking/curve behavior, signal interpretation, and + payout logic can differ by pattern. A `comparative` claim ("A better than B") is inherently relative and + zero-sum-ish; a `depositional` claim accrues differently. The economic layer reads this field to pick + mechanics. + +2. **[RENDER] Market UI variants.** A comparative predicate can render an A-vs-B versus widget; a + depositional one renders a simple stake-for/against control. The pattern drives which market component + shows. + +3. **[QUERY] Economic analytics.** "Total value staked across comparative markets" or "most active + attributive claims" segments the graph economically — only possible if the pattern is recorded. + +## Why the lower-than-leverage caveat + +Every other shipped field maps to a well-defined external concept (OWL characteristic, sentiment polarity, +fluent/event). `marketPattern`'s three values are *ours*, and their precise economic semantics aren't +written down anywhere in these docs. Before we lean harder on it for market mechanics, it warrants its own +clarification doc answering: what *exactly* distinguishes the three patterns, what mechanic does each +imply, and are three the right number? Until then it's correct to keep (it's in production and clearly +useful) but flagged as under-specified. + +## What breaks without it + +The economic layer loses its per-predicate behavior switch and falls back to one-size-fits-all market +mechanics — losing the expressiveness that makes different relationship types tradeable in fitting ways. + +## Cost / complexity + +Already paid (it exists on all specs). Ongoing cost is conceptual clarity, not authoring. + +## Pruning check + +- *Derivable?* No — economic behavior is an independent axis from logical/semantic structure. +- *Duplicated?* No external overlap; partially correlated with `claimType` (`comparative` ↔ `evaluative`) + but distinct (a comparative *market* is not the same statement as an evaluative *claim type*). +- *Mergeable?* Possibly worth examining against `claimType` in the clarification doc, but they answer + different questions (how it trades vs. what kind of claim it is). + +## Confidence breakdown + +Leverage 34/40 · Consumer readiness 23/25 · Simplicity 18/20 · Non-redundancy 9/15 → **84**. +(Deduction concentrated in non-redundancy/clarity: the values lack an external anchor and overlap +conceptually with `claimType`, pending the semantics doc.) diff --git a/.planning/predicate-fields/object-kind.md b/.planning/predicate-fields/object-kind.md new file mode 100644 index 0000000..93d458b --- /dev/null +++ b/.planning/predicate-fields/object-kind.md @@ -0,0 +1,83 @@ +# `objectKind` + +**Type:** `'entity' | 'claim' | 'literal'` +**Confidence: 92 / 100 — Core. Ship first.** + +> Verdict in one line: this is the single field that turns the predicate spec into a frontend +> **rendering contract**, and it's the only place our reification model (claims-about-claims) is made +> machine-visible. Highest leverage, a consumer that exists today, near-zero cost, fully non-redundant. + +## The problem it solves + +Given an edge `⟨subject, predicate, object⟩`, software has to decide *what the object even is* before it +can render it, traverse it, or reason about it. Today that knowledge is implicit and re-derived ad hoc. +`objectKind` declares it once, on the predicate: + +- `literal` — the object is a value, not a node (`url`, `imgUrl`, `hasDescription`, `hasSource`). +- `claim` — the object is **another statement/atom**, not an entity (`disputedBy`, `confirmedBy`, + `attestedBy`, `citedBy`, `evidencedBy`, `verifiedBy`). This is Intuition's reification surface. +- `entity` — the object is a graph node (`memberOf`, `manufacturer`, `locatedIn`). The default majority. + +## Examples across the value axes + +1. **[RENDER] The app renders any edge with zero per-predicate hardcoding.** A generic `` + component switches on `objectKind`: `literal` → render `imgUrl` as an ``, `url` as an ``, + `hasDescription` as text; `entity` → render a clickable node chip; `claim` → render a nested claim + card. Without this field, the frontend needs a giant hand-maintained `switch(predicate)` covering all + 133 predicates — exactly the kind of coupling we're trying to delete. + +2. **[QUERY] The indexer knows what not to traverse.** Graph traversal, "related entities," and + PageRank-style walks must skip `literal` objects (an image URL is not a node) and must treat `claim` + objects as edges-on-edges. Marking `objectKind` lets the indexer build a clean entity-only subgraph + for traversal and a separate reification layer for claims. + +3. **[MACHINE] Reification becomes explicit.** `disputedBy`/`confirmedBy` point at *other claims*. Our + foundations doc traced 20 years of the field struggling to represent "statements about statements" + (RDF reification → singleton properties → RDF-star). `objectKind: 'claim'` is how we declare which + predicates are meta-relations, so a consumer can follow the citation/dispute graph instead of + mistaking a claim-id for an entity-id. + +4. **[ECON] Markets on claims vs markets on entities differ.** A stake on `Alice disputedBy [claim X]` + is a second-order market (betting on the credibility of another claim). The economic layer needs to + know it's pricing a meta-claim, which `objectKind: 'claim'` flags directly. + +5. **[DATA] Validation at authoring.** "You attached an entity where this predicate expects a literal" + is catchable the moment the field is known. `url`'s object should be a URL literal, not an atom. + +## Two refinements from the audit + +- **`literalType` companion (audit A3).** "Render `imgUrl` as ``, `url` as ``" — that switch is + on the predicate key, not on `objectKind`; knowing "it's a literal" doesn't distinguish image / link / + date / number / prose. The companion field `literalType: 'url' | 'image' | 'date' | 'number' | 'text'` + (see `literal-type.md`, ships at ~85) completes the rendering contract for the literal branch — RDF's + typed-literals lesson applied here. +- **Mixed-kind policy (audit D5).** schema.org's `rangeIncludes` is famously multi-valued, and a few + predicates will have objects that are legitimately entity-or-literal (`hasSource`: URL or an entity). + The single-value enum is still right — the rendering contract needs one answer — with this tie-break: + assign the **dominant** kind; if genuinely mixed, leave the field **absent** (absent = "sniff the atom," + today's behavior) rather than guessing. + +## What breaks without it + +Every consumer re-implements object-type detection by sniffing the atom data or hardcoding predicate +lists, and the claim-vs-entity distinction stays invisible — meaning the dispute/attestation graph can't +be walked generically. The frontend `switch` becomes the de-facto (untyped, drifting) source of truth. + +## Cost / complexity + +Trivial. A single 3-value enum, mechanically assignable across all 133 specs in an afternoon (the +literal set and claim set are small and obvious). Zero reasoning cost. + +## Pruning check + +- *Derivable?* No. Atom data may sometimes reveal a literal, but the **claim** case is not derivable from + anything we store — it's semantic intent of the predicate. +- *Duplicated?* No. Closest neighbor is classifications' object typing, but that answers *which entity + type*, not *entity vs claim vs literal* — a different, coarser axis that the rendering layer needs. +- *Mergeable?* No. + +## Confidence breakdown + +Leverage 38/40 · Consumer readiness 24/25 · Simplicity 19/20 · Non-redundancy 11/15 → **92**. +(The only deduction: minor conceptual overlap with classifications typing, resolved by keeping this axis +deliberately coarse and rendering-focused.) diff --git a/.planning/predicate-fields/polarity.md b/.planning/predicate-fields/polarity.md new file mode 100644 index 0000000..31030b8 --- /dev/null +++ b/.planning/predicate-fields/polarity.md @@ -0,0 +1,79 @@ +# `polarity` + +**Type:** `'positive' | 'negative' | 'neutral'` (absent = no inherent sentiment) +**Confidence: 88 / 100 — Ship.** + +> Verdict: the field that lets a *trust/belief* graph aggregate signed signal and lets the frontend color +> sentiment, both from the predicate alone. Classical fact-graphs never needed it; Intuition cannot do +> without it. Grounded in signed-network theory (Heider; Leskovec et al.). + +## The problem it solves + +A large share of our predicates carry directional sentiment, and that sign is the raw material of +reputation. Encoding it once on the predicate means every consumer reads the same sign instead of each +re-hardcoding "trust is good, distrust is bad": + +- positive: `endorse`, `trust`, `vouchFor`, `support`, `bullishOn`, `like`, `recommend`, `backedBy` +- negative: `distrust`, `oppose`, `bearishOn`, `blocked`, `skepticalOf`, `disagreeWith`, `reported`, `votedAgainst` +- neutral: `neutralOn` (explicitly zero sentiment — distinct from *absent*) +- absent (no sentiment axis): `manufacturer`, `containedInPlace`, `authoredBy` + +## Examples across the value axes + +1. **[ECON] One reputation score across heterogeneous predicates.** Bob's standing = + Σ(stake on positive edges) − Σ(stake on negative edges), summed over `endorse`, `trust`, `vouchFor` + *and* `distrust`, `reported`, `blocked`. The scoring engine reads `polarity`; it does not carry its own + per-predicate sign table that drifts out of sync with the predicate set. + +2. **[RENDER] Sentiment-aware UI for free.** Edge cards render positive predicates green / negative red; + a profile splits incoming edges into "Supporters" and "Detractors"; a filter offers "show only + negative signals." All driven by the field, no per-predicate styling. + +3. **[QUERY] Signed-subgraph queries.** "Find entities with net-negative sentiment from accounts I trust" + requires the indexer to know each edge's sign. `polarity` makes that a first-class filter. + +4. **[MACHINE] Powers `contradicts` and balance reasoning.** Sign + the `contradicts` field together let + the system reason about structural balance ("entities my trusted peers distrust"). Signed-network + research (Leskovec, Huttenlocher & Kleinberg, 2010) shows these signs are predictable and meaningful — + we're encoding a quantity with 75 years of theory behind it (Heider 1946). + +## Direction of aggregation — the precise definition (audit D4) + +One sentence prevents a class of reputation bugs: **polarity is the sign of the edge from subject toward +object; signed aggregation accrues to the *object*** (the Heider/Leskovec signed-network convention). So +`reported` is negative *about the thing reported*, not about the reporter. Two corollaries the gate and +indexer enforce: + +- **Inverse pairs share polarity** (gate rule 8, lint): `trust`/`trustedBy` are both positive. But for the + inverse direction the aggregation target flips to the triple's *subject* — the indexer handles this by + normalizing through the canonical direction (decision record §5.2) before aggregating, so every signed + edge is summed exactly once, onto the right node. +- Never mark a sentiment predicate transitive (gate rule 10; see `is-transitive.md`). + +## Why `neutral` is kept (and absent is different) + +`neutralOn` is a *deliberate* zero — "I have considered this and have no lean," which is information. +That's distinct from a relational predicate like `manufacturer` that has *no sentiment axis at all* +(absent). Collapsing them would lose the "explicitly neutral" signal that markets may price. (Open +question flagged in the decision record; current lean: keep the 3-value enum + absence.) + +## What breaks without it + +Every reputation/scoring/rendering surface maintains its own predicate→sign lookup. These drift, and new +predicates silently score as zero until each consumer is patched. Sentiment becomes tribal knowledge. + +## Cost / complexity + +Low. The sign of a predicate is obvious to assign; ~one afternoon across 133 specs. Trivial to consume. + +## Pruning check + +- *Derivable?* No — sentiment is semantic, not structural. +- *Duplicated?* Orthogonal to `claimType`: `trust` is positive *and* evaluative; `blocked` is negative + *and* factual; `manufacturer` is absent *and* factual. Two independent axes. +- *Mergeable?* No. + +## Confidence breakdown + +Leverage 36/40 · Consumer readiness 22/25 · Simplicity 19/20 · Non-redundancy 11/15 → **88**. +(Deduction: the `neutral`-vs-absent subtlety is a small ongoing modeling cost.) diff --git a/.planning/predicate-fields/specializes.md b/.planning/predicate-fields/specializes.md new file mode 100644 index 0000000..e05a29e --- /dev/null +++ b/.planning/predicate-fields/specializes.md @@ -0,0 +1,70 @@ +# `specializes` + +**Type:** `readonly PredicateKey[]` (this predicate is a sub-property of each referenced one) +**Confidence: 80 / 100 — Ship.** + +> **Type widened by the audit (`predicate-spec-audit.md` B1):** originally a single `PredicateKey`. +> Property hierarchies are **DAGs, not trees** — RDFS `subPropertyOf`, WordNet hypernymy, and schema.org +> all permit multiple broader terms, and real cases hit it immediately (`founder` specializes +> `affiliatedWith` *and* a creation relation; `authoredBy` sits under both `contributedTo` and +> provenance). Widening later is a migration; widening now is free, and it matches `contradicts`' shape. +> Curation guidance — usually one parent, never more than two — becomes a lint warning, not a type +> constraint. The gate also enforces acyclicity (rule 5) and closes `contradicts` down the hierarchy +> (rule 7). + +> Verdict: the cheapest high-leverage inference in the whole stack — roll-up. A specific edge entails the +> general one, so broad queries and reputation catch every specific sub-relation for free. Descends from +> RDFS `subPropertyOf` and KL-ONE inheritance (1985); echoed by WordNet hypernymy. + +## The problem it solves + +Specific relationships imply broader ones. `specializes` declares the hierarchy so consumers query/score +at the general level and automatically include the specifics: + +- `employedBy`, `alumniOf`, `memberOf`, `founder` → all `specializes` `affiliatedWith` +- `endorse`, `vouchFor` → `specializes` `support` (or a general positive-regard predicate) +- `subOrganization`, `branchOf` → `specializes` a general containment relation +- `authoredBy`, `createdBy`, `director`, `producer` → `specializes` `contributedTo` + +## Examples across the value axes + +1. **[QUERY] Roll-up without enumeration.** "Everyone affiliated with Acme" returns employees, alumni, and + members in one query, because each specific predicate `specializes affiliatedWith`. The query author + writes one predicate; the indexer expands the hierarchy. Without it, the query must hand-list every + sub-predicate and silently miss any added later. + +2. **[ECON] Reputation at the right granularity.** A "general affiliation" reputation signal aggregates all + sub-relations; a "contribution" score sums `authoredBy` + `createdBy` + `contributedTo`. The scoring + engine walks `specializes` instead of maintaining its own grouping table. + +3. **[RENDER] Faceted, grouped UI.** A profile groups edges under collapsible headings ("Affiliations → + employed by, alumnus of, member of") generated from the predicate hierarchy. New sub-predicates appear + under the right heading automatically. + +4. **[MACHINE] Forward-chainable entailment.** `P specializes Q` and `P(a,b)` ⟹ `Q(a,b)` is pure OWL 2 RL — + cheap, monotonic, computable at graph scale. It's the highest value-per-compute axiom we have. + +## What breaks without it + +Every "broad" query and every reputation grouping hardcodes the list of sub-predicates, and those lists +rot the moment a new predicate is added. The graph can't answer general questions without enumerating +specifics — defeating much of the point of a semantic layer. + +## Cost / complexity + +Moderate authoring — it needs **human curation** to define the hierarchy correctly (and to avoid asserting +false generalizations). Cheap to consume. The hierarchy is shallow (1–2 levels) for most predicates. + +## Pruning check + +- *Derivable?* No — the hierarchy is editorial/semantic. +- *Duplicated?* Distinct from `category` (which is a flat classification bucket for discovery, not an + entailment relation). `category` groups for *browsing*; `specializes` groups for *inference*. Worth + stating explicitly so the two don't get conflated. +- *Mergeable?* No — `category` can't carry entailment. + +## Confidence breakdown + +Leverage 35/40 · Consumer readiness 18/25 · Simplicity 15/20 · Non-redundancy 12/15 → **80**. +(Deductions: curation cost and a roll-up-query consumer that's emerging rather than fully built. The +inference is so cheap and so central that it stays a clear ship.) diff --git a/.planning/predicate-fields/superseded-by.md b/.planning/predicate-fields/superseded-by.md new file mode 100644 index 0000000..7eb37ee --- /dev/null +++ b/.planning/predicate-fields/superseded-by.md @@ -0,0 +1,58 @@ +# `supersededBy` + +**Type:** `PredicateKey` — only valid when `status === 'deprecated'` +**Confidence: 70 / 100 — Soft-ship.** *(Added by the audit pass, `predicate-spec-audit.md` D1.)* + +> Verdict: deprecation without forwarding is a dead end. Wikidata's "replaced by" is the precedent; with +> 133 community-evolving specs, predicate succession will be routine, and the pointer is one optional key. + +## The problem it solves + +`status: 'deprecated'` currently tells consumers only "don't use this." Nothing tells: + +- a **picker** what to offer instead (it can only hide the predicate), +- a **renderer** of existing triples how to present an old edge ("this used `partneredWith`, superseded by + `partnerOf`"), +- a **migration script** where old edges should map. + +`supersededBy` is the forwarding address. + +## Examples across the value axes + +1. **[RENDER] Pickers hide-and-redirect.** Selecting or searching a deprecated predicate surfaces "use + `partnerOf` instead" — driven by the field, not a hardcoded redirect table. + +2. **[DATA] Old triples stay legible.** Edges minted under a deprecated predicate render with a + "superseded" affordance linking to the successor, instead of silently rotting or disappearing from + grouped views. + +3. **[QUERY] Optional read-time widening.** A query on the successor can opt in to including + edges asserted under its deprecated predecessors — the same shape as `specializes` roll-up, reusing + that machinery. + +4. **[MACHINE] Serializes to `schema:supersededBy`** — an existing schema.org term, so the succession + chain is standards-legible. + +## Why soft-ship, not ship + +The consumer (pickers/migration tooling) is real but the *population* is nearly empty today — few +predicates are deprecated yet. Define the field and validation now; populate as deprecations happen. +That's exactly the soft-ship band. + +## Cost / complexity + +Trivial. One optional key; validated by rule 14 in `definePredicateRecord` (must point at a +non-deprecated spec; only valid on `status: 'deprecated'`; chains permitted but must be acyclic). + +## Pruning check + +- *Derivable?* No — succession is editorial. +- *Duplicated?* No. `status` says *that* it's retired; this says *what replaced it*. Distinct from + `specializes` (entailment between live predicates) and `inverse` (direction pairing). +- *Mergeable?* No. + +## Confidence breakdown + +Leverage 24/40 · Consumer readiness 18/25 · Simplicity 19/20 · Non-redundancy 13/15 → **70**. +(Deductions: leverage is modest — lifecycle hygiene, not new query power — and today's deprecated set is +small. The cost is so low that the field still clears its band comfortably.) diff --git a/.planning/predicate-fields/temporal-nature.md b/.planning/predicate-fields/temporal-nature.md new file mode 100644 index 0000000..6f0b225 --- /dev/null +++ b/.planning/predicate-fields/temporal-nature.md @@ -0,0 +1,67 @@ +# `temporalNature` + +**Type:** `'permanent' | 'state' | 'event'` +**Confidence: 85 / 100 — Ship.** + +> Verdict: the only field that tells the system which edges *rot*. Drives freshness UX today and data +> lifecycle long-term. Grounded in 50+ years of AI knowledge representation (fluents vs events) and in +> OntoClean's rigidity meta-property. + +## The problem it solves + +Edges age very differently, and nothing in the current schema captures it. `temporalNature` classifies +the relationship's relationship-to-time: + +- `permanent` — true forever once true; never decays: `authoredBy`, `derivedFrom`, `founded`, + `precededBy`, `forkedFrom`, `byArtist`. +- `state` — a condition that holds over an interval and can silently become false: `employedBy`, + `memberOf`, `locatedIn`, `governedBy`, `availableOn`, `pinnedIn`. +- `event` — a point-in-time fact that stays true *as history*: `votedFor`, `reviewed`, `attestedBy`, + `triggered`. + +## Examples across the value axes + +1. **[RENDER] Freshness badges, automatically.** A `state` edge like `employedBy` renders an "as of {date}" + badge and a "still true?" affordance; a `permanent` edge like `authoredBy` renders neither. The UI + derives this from `temporalNature` instead of a hardcoded list of "decayable" predicates. + +2. **[DATA] Targeted re-attestation.** A data-quality job re-checks only `state` edges (those are the ones + that go stale) and never wastes work re-validating `permanent` ones. Without the field, you either + re-validate everything or nothing. + +3. **[QUERY] "Current" vs "historical" queries.** "Where does Alice work *now*?" must consider only the + most recent live `state` edge; "every company Alice has worked at" wants the full `event`-like history. + The query layer needs to know which model applies — `state` edges supersede, `event`/`permanent` edges + accumulate. + +4. **[ECON] Market resolution windows.** A market on a `state` claim (`Alice memberOf DAO`) is naturally + time-bounded and may need re-opening when the state could have changed; a market on a `permanent` + claim (`Alice founded DAO`) resolves once and stays resolved. Different economic lifecycles. + +5. **[MACHINE] Maps to a deep, validated distinction.** Situation/event calculus (McCarthy & Hayes 1969; + Kowalski & Sergot 1986) and OntoClean's rigidity (Guarino & Welty 2002) both encode exactly this + fluent/event/essential split. We're not inventing a category; we're naming a well-studied one. + +## What breaks without it + +The graph has no model of freshness. Stale `state` edges (`employedBy` from three jobs ago) look as +authoritative as permanent facts, and the UI can't distinguish "fact" from "expired condition." For a +graph meant to represent reality over time, that's a structural blind spot. + +## Cost / complexity + +Low. The three-way classification is usually obvious from the predicate's meaning. No reasoning cost — it's +a hint consumed by UI and data jobs, not an inference rule. + +## Pruning check + +- *Derivable?* No. +- *Duplicated?* No — orthogonal to every other field. (A `permanent` predicate can be positive, factual, + symmetric, etc.) +- *Mergeable?* No; the three values each drive distinct behavior. + +## Confidence breakdown + +Leverage 34/40 · Consumer readiness 21/25 · Simplicity 19/20 · Non-redundancy 11/15 → **85**. +(Deduction: full payoff for `state` edges depends on a validity-interval mechanism that doesn't exist yet; +the rendering/lifecycle value lands immediately regardless.) diff --git a/.planning/predicate-query-optimization-spec.md b/.planning/predicate-query-optimization-spec.md new file mode 100644 index 0000000..d688e3c --- /dev/null +++ b/.planning/predicate-query-optimization-spec.md @@ -0,0 +1,329 @@ +# Spec: Predicate-Aware Query Optimization for Metadata & Identity Traversal + +**Status:** Technical spec — draft for review +**Companion to:** `predicate-backend-implementation-plan.md`, `predicate-backend-integration.md` +**Backend verified:** `alpha/backend` + `@0xintuition/database-kg` (Drizzle), 2026-07-01 +**Focus:** indexing the KG DB, parsing predicates, and enabling connected-triple lookups from a root item + +--- + +## 1. The problem, concretely + +**Scenario (real — it exists today as `workers/scripts/run-kg-spotify-smoke.ts`):** a song atom is created +from a Spotify link. Its `data_resolved` is schema.org JSON-LD: + +```json +{ "@type": "MusicRecording", "name": "Man I Need", "byArtist": "Olivia Dean", + "sameAs": ["https://open.spotify.com/track/1qbmS6ep2hbBRaEZFpn7BX"] } +``` + +Later, someone connects an Apple Music link so users know it's the same song — **via a URL predicate edge** +`(song, url, appleMusicAtom)`. Now "everything we know about this song" is spread across: + +- the node's **own embedded metadata** (`data_resolved`: name, artist, Spotify url), and +- **connected triple edges** (the new Apple Music url; tags; listed-in; reviews; …), and +- possibly **co-referent nodes** declared equivalent (`sameAs`). + +### The architectural shift this forces + +| | Today | Target | +|---|---|---| +| Metadata (url, image, artist) | embedded in `data_resolved` JSONB | **also** first-class triple **edges** | +| Identity (`sameAs`) | a string array inside `data_resolved`; `canonical_url` denormalized onto `search.documents` | **edges** + a resolved **equivalence class** | +| "Find X about this item" | JSON extract (`data_resolved->>'url'`) / exact `canonical_url` match | **traverse connected triples**, predicate-aware | + +The current JSON approach is fast for the single node but **cannot answer graph questions** — "all URLs for +this song across platforms," "everything anyone has asserted about it," "what is this the same as." Moving +to triple-native traversal is what unlocks that; this spec makes it performant. A complete answer must +**union embedded metadata + edges, across the equivalence class**. + +--- + +## 2. Query taxonomy (the read patterns to optimize) + +All over `kg.triples` (subject_id, subject_type, predicate_id, object_id, object_type, status, visibility, +confidence, inferred, metadata) joined to `kg.predicates` and `kg.nodes`. + +| # | Pattern | Shape | Notes | +|---|---|---|---| +| Q1 | **Direct attributes** | `subject_id = R` | outgoing edges of root | +| Q2 | **Incoming edges** | `object_id = R` | who references root | +| Q3 | **Typed multi-value** | `subject_id = R AND predicate_id = url` | *the Spotify+Apple case — two url edges on one node* | +| Q4 | **Identity/co-reference** | closure over `sameAs` (± inverse-functional keys) | the equivalence class of R | +| Q5 | **Cross-class metadata gather** | Q1/Q3 over **all class members** | "everything about this song" | +| Q6 | **Roll-up / reverse / transitive** | expand `specializes`; add `inverse`/`isSymmetric`; `isTransitive` closure | predicate-metadata-driven | + +Q3 is the common, cheap case (multi-valued attribute on one node). Q4/Q5 are the hard, general case +(co-reference). We optimize both, but they need different machinery. + +--- + +## 3. Parsing predicates → the in-memory predicate registry + +"Properly index and parse predicates" = load `kg.predicates` (seeded from the package, per the +implementation plan) into a **typed registry keyed by predicate atom id**, built once at startup and +refreshed on seed change. `kg.predicates` is tiny (hundreds of rows), read on every traversal → cache it; +never join it per-query on the hot path. + +The registry classifies each predicate into **roles** derived from our fields: + +```ts +interface PredicateRole { + id: string; slug: string; + objectKind: 'entity' | 'claim' | 'literal'; // how to handle the object during gather + isMetadataLiteral: boolean; // objectKind === 'literal' → collect value + literalType?: 'url'|'image'|'date'|'number'|'text'; // audit A3 — how to resolve/render the value + isEquivalence: boolean; // EXPLICIT allow-list (sameAs) — see note below + isIdentityKey: boolean; // isInverseFunctional → shared object ⇒ same subject + isSymmetric: boolean; + isTransitive: boolean; + inverseId?: string; // reverse-direction predicate + isCanonicalDirection: boolean; // audit A1 — inverse pairs normalize to one direction + specializesIds: string[]; // parent predicates (roll-up) — array/DAG per audit B1 + polarity?: 'positive'|'negative'|'neutral'; + temporalNature?: 'permanent'|'state'|'event'; +} +``` + +Fast lookup structures the query builder consults: + +- `HashMap` — the registry. +- `HashSet` per role: `EQUIVALENCE_PREDS`, `IDENTITY_KEY_PREDS`, `METADATA_LITERAL_PREDS`, + `SYMMETRIC_PREDS`. +- `HashMap>` `SPECIALIZES_CHILDREN` (parent → descendants) for roll-up expansion. +- `HashMap` `INVERSE_OF`. + +This registry is the "parsed predicate" layer. Everything downstream (gather, equivalence, rewrite) is a +function of it — so adding a predicate or changing a field is a data change, not a code change. + +> **Guardrail (audit C2): `isEquivalence` is an explicit allow-list, never inferred from algebra.** +> `isSymmetric + isTransitive` mathematically characterizes an equivalence-like relation, and it's +> tempting to let any such predicate trigger identity behavior. Don't: identity merge is the +> highest-blast-radius inference in the system (no-UNA "smushing" — one bad edge merges two entities' +> metadata, stake context, and reputation), and inferring the behavior from two booleans means a future +> author can create it *by accident*. Equivalence-class membership is opt-in by predicate key — today +> **`sameAs` only** (`equivalentTo` was cut from the spec). The symmetric+transitive combination on any +> other predicate is a build-time lint (decision record §5.1 rule 9). + +> **Atom parsing note:** the atom parse/enrich workers (`kg-parse-worker`, `kg-enrichment-worker`) that +> populate `data_resolved` should *also* be able to **emit edges from JSON-LD properties** (`url`, `sameAs`, +> `byArtist`) so embedded metadata becomes queryable triples. That's the ingestion half of the shift in §1; +> this spec assumes it and focuses on read/index. + +--- + +## 4. Indexing `kg.triples` (the core of "update our indexing database") + +Today `kg.triples` has only PK + the predicate FK → every traversal is a seq scan. Required indexes, +tuned to §2 and designed for **index-only scans** (INCLUDE the columns the gather needs so Postgres skips +the heap): + +```sql +-- Q1, Q3, Q5 forward: attributes of a node/class +CREATE INDEX ix_triples_subject ON kg.triples + (subject_id, predicate_id) INCLUDE (object_id, object_type, confidence) + WHERE status = 'active' AND inferred = false; + +-- Q2 reverse + symmetric/inverse views +CREATE INDEX ix_triples_object ON kg.triples + (object_id, predicate_id) INCLUDE (subject_id, subject_type) + WHERE status = 'active' AND inferred = false; + +-- Q6 roll-up IN-scans / per-predicate analytics +CREATE INDEX ix_triples_predicate ON kg.triples (predicate_id, subject_id); + +-- Identity-key lookup ("which subjects share this url object?") — Q4 via IFP +CREATE INDEX ix_triples_object_predicate ON kg.triples + (object_id, predicate_id) INCLUDE (subject_id) + WHERE status = 'active'; -- (broader than base view; identity spans states) + +-- Equivalence edges: small, hot, stable predicate set +CREATE INDEX ix_triples_equivalence ON kg.triples (subject_id, object_id) + WHERE predicate_id = ANY () AND status = 'active'; +``` + +Design rationale: + +- **Partial `WHERE status='active' AND inferred=false`** keeps the base-view indexes small and makes + "exclude synthesized/rolled-up edges" the default (no double-counting). +- **`INCLUDE` covering columns** → index-only scans for gather; the object value fetch is a separate + batched `kg.nodes` lookup (§6), not a per-row heap hit. +- The **equivalence partial index** is pinned to the known equivalence predicate ids from the seed, so + `sameAs` traversal never scans unrelated edges. +- Reassess after load: for very hot single predicates (`url`, `hasTag`) a dedicated partial index per + predicate id can beat the shared one. + +Also index the **object value path** used for URL identity on `kg.nodes`: +```sql +CREATE INDEX ix_nodes_url ON kg.nodes ((data_resolved->>'url')); -- existing JSON path lookups +CREATE INDEX ix_nodes_search_text ON kg.nodes USING gin (to_tsvector('simple', search_text)); +``` + +--- + +## 5. Identity / co-reference resolution engine (Q4/Q5) + +The equivalence class of a node is the connected component under **equivalence predicates** (the explicit +allow-list from §3 — today `sameAs` only) plus **identity keys** (any predicate flagged +`isInverseFunctional`: two subjects with the same object are the same entity). + +> In the Spotify+Apple example the two URLs *differ*, so the IFP-on-url rule does **not** auto-merge them — +> that case is either a **multi-valued attribute** on one node (Q3, cheap) or an **explicit `sameAs`** edge +> between two representation nodes (Q4). IFP auto-merge fires when two nodes genuinely share one url object. + +Two implementations: + +### 5A. Query-time (recursive CTE) — good for small classes, zero maintenance +```sql +WITH RECURSIVE class AS ( + SELECT $1::text AS node_id + UNION + SELECT CASE WHEN t.subject_id = c.node_id THEN t.object_id ELSE t.subject_id END + FROM class c + JOIN kg.triples t + ON (t.subject_id = c.node_id OR t.object_id = c.node_id) + AND t.predicate_id = ANY () + AND t.status = 'active' +) SELECT node_id FROM class; -- UNION dedups / terminates cycles +``` +Cost grows with class size and is paid on **every read**. Fine at low scale. + +### 5B. Materialized canonical id (recommended) — O(1) class lookup, incremental maintenance +New read model + projector: +```sql +CREATE TABLE kg.node_equivalence ( + node_id text PRIMARY KEY, + canonical_id text NOT NULL, + class_version bigint NOT NULL DEFAULT 0 +); +CREATE INDEX ix_node_equiv_canonical ON kg.node_equivalence (canonical_id); +``` +- A new **`EquivalenceProjection`** (indexing-services, alongside `core_entities`) runs a **union-find**: + on each `sameAs`/`equivalentTo` triple (and each IFP-key collision), `union(a, b)`; the canonical id is + deterministic (e.g. lowest atom id, or the earliest-created node) so results are stable and replay-safe. +- Class lookup = `SELECT canonical_id FROM kg.node_equivalence WHERE node_id = $1` then all members via the + `canonical_id` index. No recursion on read. +- **Merges:** joining two classes re-points the smaller class's members (path-compression); bump + `class_version` to invalidate caches (§7). +- **Splits (retraction):** redacting a `sameAs` can split a component — expensive and rare. Handle by + marking the class dirty and recomputing that component (bounded BFS), not the whole table. + +**Recommendation:** ship **5A first** (no migration, correct), add **5B** once class reads are hot or +classes grow. Same interface (`equivalenceClass(nodeId) → id[]`) so the query builder doesn't change. + +--- + +## 6. Predicate-aware metadata gather (Q5) — the algorithm + +``` +gatherMetadata(rootId, { hops = 1, includeInferred = false }): + classIds = equivalenceClass(rootId) // §5 (5A or 5B) + + edges = SELECT subject_id, predicate_id, object_id, object_type, confidence + FROM kg.triples + WHERE subject_id = ANY(classIds) + AND status='active' AND visibility='public' + AND (includeInferred OR inferred=false) // uses ix_triples_subject + + result = {} // slug -> value[] + objectIds = [] + for e in edges: + role = registry[e.predicate_id] // §3, no DB hit + switch role.objectKind: + 'literal': objectIds.push(e.object_id) // resolve value in batch below + result[role.slug] += { ref: e.object_id, kind:'literal' } + 'entity' : result[role.slug] += { ref: e.object_id, kind:'entity' } // recurse if hops>1 + 'claim' : result[role.slug] += { ref: e.object_id, kind:'claim' } + + // one batched fetch — avoids N+1 + values = SELECT id, data_resolved, classification_type + FROM kg.nodes WHERE id = ANY(objectIds) + splice resolved values into result[*].kind=='literal' + + // merge each class member's OWN embedded data_resolved (§1 completeness) + embedded = SELECT id, data_resolved FROM kg.nodes WHERE id = ANY(classIds) + merge embedded JSON-LD (url, sameAs, byArtist, …) into result, deduped + + // optional Q6: reverse/inverse edges, roll-up expansion (registry-driven) + return result +``` + +Key properties: **two batched queries** (edges by class; object values) + one embedded-data fetch — no +per-edge round trips; predicate handling comes from the in-memory registry, not joins; inferred excluded by +default so nothing is double-counted; embedded JSON-LD and edges are unioned for completeness. + +--- + +## 7. Read model, caching, guardrails + +- **Optional metadata projection** `kg.node_metadata(canonical_id PK, resolved jsonb, class_version)` — + precomputes §6 output per class for "give me the card for this item" in one read. Rebuilt by a projector + on relevant writes. Trade write cost for read latency; add only when gather is measured hot. +- **Cache** `canonical_id → resolved` in the read service, keyed by `class_version`; a class merge/edge + write bumps the version → natural invalidation. +- **Guardrails:** + - **Class-size cap** (e.g. 10k members) with logged truncation — a bad `sameAs` chain shouldn't fan out + unbounded. Never silently truncate without a signal. + - **Cycle-safe** equivalence (UNION / visited set). + - **Depth cap** on entity recursion (`hops`) and on transitive closure (§ integration doc, ~8). + - **OWA**: gather reads only explicit, active assertions. + +--- + +## 8. Pipeline & code changes + +| Area | Change | Where | +|---|---|---| +| Schema | §4 indexes; `kg.node_equivalence` (5B); optional `kg.node_metadata` | KG migrations (per impl-plan P0) | +| Predicate registry | load seed → in-memory role registry + lookup sets; refresh on change | KG read layer (`@0xintuition/database-kg`) + recommendation-service | +| Atom parse | emit edges from JSON-LD props (`url`, `sameAs`, `byArtist`) so embedded metadata becomes triples | `kg-parse-worker` / enrichment workers | +| Equivalence | `EquivalenceProjection` (union-find → `kg.node_equivalence`) | `indexing-services/crates/projections` | +| Metadata | (optional) `MetadataProjection` → `kg.node_metadata` | same | +| Query builder | `equivalenceClass()`, `gatherMetadata()`, roll-up/inverse rewrite — Drizzle | `@0xintuition/database-kg/actions` | +| API | expose gather + typed-neighbor endpoints; `includeInferred` toggle | `api/src/clients/kg-*.ts`, routes | + +All projectors idempotent + replay-safe (`ON CONFLICT`), matching the existing `core_entities` pattern, and +graceful no-op when `DATABASE_KG_URL` is absent. + +--- + +## 9. Performance expectations + +| Query | Without spec | With spec | +|---|---|---| +| Q1/Q3 attributes | seq scan O(triples) | index-only scan O(deg(node)) via `ix_triples_subject` | +| Q2 incoming | seq scan | `ix_triples_object` | +| Q4 class | recursive seq scans | 5A bounded CTE on `ix_triples_equivalence`; 5B O(1) lookup | +| Q5 gather | N+1 explosion | 2 batched index scans + registry (no per-edge joins) | +| Q6 roll-up | enumerate sub-preds by hand | registry expansion → single `predicate_id = ANY(...)` on `ix_triples_predicate` | + +Target: single-item metadata card in **one or two index-only scans + one batched node fetch**, independent +of total graph size; class resolution O(1) under 5B. + +--- + +## 10. Deliverables & acceptance criteria + +1. **Indexes (§4)** applied; `EXPLAIN` shows index-only scans for Q1–Q3, no seq scan on `kg.triples`. +2. **Predicate registry (§3)** loaded from seed; role lookups are in-memory (0 DB hits on the hot path). +3. **Equivalence (§5)** — `equivalenceClass(spotifySong)` returns the Apple Music co-referent(s); correct + under cycles; stable canonical id. +4. **Gather (§6)** — `gatherMetadata(song)` returns *both* platform URLs and unions embedded + edge + metadata in ≤ 2 batched queries; excludes inferred by default; no double-count. +5. **Guardrails (§7)** — class-size cap logs truncation; caches invalidate on `class_version` bump. +6. **Bench** — p95 for the single-item card under target on a graph ≥ 10× current size. +7. Reuse/extend the **Spotify smoke test** as the end-to-end fixture. + +--- + +## 11. Open questions + +1. Do we **migrate** embedded `data_resolved` metadata (url/sameAs) into edges, or keep both and union at + read (§6)? (Union-at-read is lower-risk; edge emission can backfill over time.) +2. Which predicates are **identity keys** (`isInverseFunctional`)? `url` per-platform is *not* globally + unique (same song, many urls) — so IFP likely applies to canonical/isrc-style ids, not raw platform urls. + This decides how much auto-merge is safe (smushing risk). +3. Materialize now (5B / `kg.node_metadata`) or start query-time (5A) and promote on measured pressure? +4. Retraction/split policy for `sameAs` — recompute-on-dirty vs periodic full rebuild. +5. Does gather cross **visibility/creator** boundaries, or only the requester's trusted set? (Affects + whether class expansion is global or scoped.) diff --git a/.planning/predicate-semantics-foundations.md b/.planning/predicate-semantics-foundations.md new file mode 100644 index 0000000..e35039c --- /dev/null +++ b/.planning/predicate-semantics-foundations.md @@ -0,0 +1,344 @@ +# Foundations: OWL/RDF Property Characteristics & 30 Years of Knowledge-Graph Research + +**Status:** Reference / background +**Companion to:** `predicate-semantics.md` (the concrete design) +**Date:** 2026-06-30 + +This document is the intellectual grounding for the predicate-semantics design. Part I is a deep +reference on the formal property characteristics from RDF/RDFS/OWL. Part II traces the lineage of +knowledge-graph research from ~1968 to today and extracts what each era teaches us. Part III maps +that body of work directly onto the Intuition predicate fields. Part IV records the tensions the +research warns us about — the mistakes a 30-year field has already made so we don't repeat them. + +--- + +# Part I — OWL / RDF property characteristics in depth + +## I.0 Why the predicate is the unit of meaning + +RDF models the world as **triples**: `⟨subject, predicate, object⟩`. The predicate (a.k.a. property) +is not decoration — it *is* the edge, and in RDF it is itself a first-class resource that can be +described, typed, and reasoned about. This is exactly Intuition's model: a predicate is an atom, and +the relationship's meaning is carried by that atom. So everything the Semantic Web learned about +"describing a property" applies to us directly. + +The stack has three layers of increasing commitment: + +| Layer | Year(s) | What it adds | Reasoning cost | +|---|---|---|---| +| **RDF** | 1999 / 2004 | triples, resources, literals | none (data model) | +| **RDFS** | 2004 | `subClassOf`, `subPropertyOf`, `domain`, `range` | light, tractable | +| **OWL / OWL 2** | 2004 / 2009 | property *characteristics*, disjointness, cardinality, chains | up to undecidable (OWL Full); decidable fragments (OWL DL, EL/QL/RL profiles) | + +OWL is grounded in **Description Logic (DL)** — OWL DL ≈ the DL `SHOIN(D)`; OWL 2 DL ≈ `SROIQ(D)` +(Horrocks, Kutz & Sattler, 2006). DL gives every axiom a precise model-theoretic semantics and a +known complexity, which is what makes "machine-readable" actually mean "machine-*reasonable*." + +## I.1 The property axioms, one by one + +For each: the formal rule, what it lets a reasoner *do*, and the Intuition field it maps to. + +### Object vs Datatype property → `objectKind` +OWL splits `owl:ObjectProperty` (object is a resource/node) from `owl:DatatypeProperty` (object is a +literal value). A reasoner must never traverse a datatype edge as if it were a node. This is precisely +our `objectKind: 'entity' | 'literal'` distinction (`url`, `imgUrl`, `hasDescription` are datatype-like). + +### `rdfs:subPropertyOf` → `specializes` +**Rule:** `P ⊑ Q` and `P(a,b)` ⟹ `Q(a,b)`. Every specific edge entails the general one. +**Does:** roll-up. A query over `affiliatedWith` automatically catches `employedBy`, `alumniOf`, +`memberOf`. Property hierarchies are the cheapest, highest-leverage inference in the whole stack and +they descend directly from KL-ONE's structured inheritance (1985). + +### `owl:inverseOf` → `ontology.inverse` +**Rule:** `P ≡ Q⁻` ⟹ `P(a,b) ↔ Q(b,a)`. +**Does:** store one direction, answer queries in both. `parentOrganization ⇄ subOrganization`, +`follow ⇄ followedBy`. Note OWL references the inverse *property*, not a display string — which is why +our design insists `inverse` be a typed `PredicateKey`. + +### `owl:SymmetricProperty` → `logic.symmetric` +**Rule:** `P(a,b) ⟹ P(b,a)`. Formally, `P ≡ P⁻` (a property that is its own inverse). +**Does:** synthesize the reciprocal edge. `affiliatedWith`, `partnerOf`, `competeWith`, `sameAs`. + +### `owl:AsymmetricProperty` (OWL 2) → `logic.asymmetric` +**Rule:** `P(a,b) ⟹ ¬P(b,a)`. Strictly stronger than "not symmetric." +**Does:** contradiction detection — asserting both directions is *inconsistent*. `parentOrganization`, +`betterThan`, `founder`. Asymmetry **entails irreflexivity** (see I.2). + +### `owl:TransitiveProperty` → `logic.transitive` +**Rule:** `P(a,b) ∧ P(b,c) ⟹ P(a,c)`. +**Does:** reachability and closure. `containedInPlace`, `parentOrganization`, `rankedAbove`, `dependOn`. +Caveat OWL learned the hard way: combining transitivity with cardinality restrictions breaks +decidability, so OWL DL forbids it on the same property. A lesson in not stacking powers naively. + +### `owl:ReflexiveProperty` / `owl:IrreflexiveProperty` (OWL 2) → `logic.reflexive` / `irreflexive` +**Rule:** reflexive ⟹ `P(a,a)` for all `a`; irreflexive ⟹ `¬P(a,a)` for all `a`. +**Does:** self-loop validation. `sameAs` is reflexive; the overwhelming majority of relationship +predicates are irreflexive (nothing is `betterThan` itself). + +### `owl:FunctionalProperty` → `logic.functional` +**Rule:** `P(a,b) ∧ P(a,c) ⟹ b = c`. Each subject has at most one object. +**Does:** uniqueness validation and, under no-UNA (I.4), *identity inference* — two objects asserted +for a functional property are inferred to be the same thing. `manufacturer`, `primaryImageOfPage`. + +### `owl:InverseFunctionalProperty` (IFP) → `logic.inverseFunctional` +**Rule:** `P(a,c) ∧ P(b,c) ⟹ a = b`. The object is a *key* that identifies the subject. +**Does:** entity resolution. This is how the Semantic Web does deduplication without a database join — +an IFP like `linkedAccount` says "whoever links this account is the same person." Extremely relevant +to a permissionless graph where the same entity is minted many times. + +### `owl:propertyDisjointWith` (OWL 2) → `ontology.contradicts` +**Rule:** `P` disjoint `Q` ⟹ no pair `(a,b)` may satisfy both. +**Does:** consistency. `trust ⊥ distrust`, `bullishOn ⊥ bearishOn`, `support ⊥ oppose`. This is the +formal hook for Intuition's disagreement/contradiction markets. + +### `owl:equivalentProperty` → `ontology.equivalentTo` +**Rule:** `P ≡ Q` ⟹ mutual sub-property; the two are interchangeable. Use for true aliases only. + +### `owl:propertyChainAxiom` (OWL 2 SROIQ) → P2 / future +**Rule:** `P ∘ Q ⊑ R`, e.g. `parentOf ∘ parentOf ⊑ grandparentOf`; `locatedIn ∘ containedInPlace ⊑ locatedIn`. +**Does:** compose new edges from paths. The most powerful and most expensive axiom; SROIQ admits it only +under a *regularity* (acyclicity) restriction to stay decidable. Park it until we have a reasoning layer. + +### Property restrictions / `domain` & `range` +RDFS `domain`/`range` and OWL value/cardinality restrictions type the endpoints. **We deliberately do +not duplicate these into specs** — typed targets live in `@0xintuition/classifications`. Worth knowing +the trap: in RDFS, `domain`/`range` are *axioms that entail types*, not *constraints that reject data*. +Asserting `alumniOf(x, y)` doesn't reject a non-Person `x`; it **infers** `x` is a Person. SHACL/ShEx +(2017) were later invented precisely to get *validation* (closed-world checks) back. Intuition wants +validation semantics, so this belongs in the classifications/SHACL-style layer, not OWL-style specs. + +## I.2 Characteristics are not independent — the constraint lattice + +A reasoner (and our `definePredicateRecord` derivation layer) must respect entailments *between* +characteristics: + +- `asymmetric ⟹ irreflexive` +- `symmetric ∧ irreflexive ⟹` cannot also be asymmetric +- `reflexive ⟹ ¬irreflexive` (and a property may be neither) +- `symmetric ⟹ inverse = self` +- `transitive ⟹ inverse is transitive`; `symmetric ⟹ inverse is symmetric` +- an inverse pair must mirror each other's characteristics +- a functional + transitive property over an irreflexive domain has strong implications for chains + +These are exactly the cross-checks the design assigns to the derivation/validation step. Declaring +inconsistent flags should be a build-time error, not a silent record. + +## I.3 The expressivity ↔ tractability frontier (the central lesson of OWL) + +OWL shipped **three species** (Lite, DL, Full) and OWL 2 added **three profiles** — not for fun, but +because the field discovered that *more reasoning power costs more compute, up to undecidable*: + +| Profile | Optimized for | Drops | +|---|---|---| +| **OWL 2 EL** | huge terminologies (e.g. SNOMED医) | disjunction, inverse, cardinality | +| **OWL 2 QL** | query rewriting over databases | most existentials | +| **OWL 2 RL** | rule engines / forward-chaining at scale | some equality reasoning | + +**The lesson for Intuition:** decide *how much entailment we are willing to compute at graph scale* +before we adorn predicates with axioms we can never run. A property-hierarchy + inverse + symmetry + +disjointness reasoner (essentially OWL 2 RL) is cheap, forward-chainable, and covers ~90% of our value. +Transitive closure is moderate. Property chains and full equality reasoning are where costs explode. +Our P0/P1/P2 tiers track this frontier on purpose. + +## I.4 Two assumptions that change everything: OWA & no-UNA + +- **Open World Assumption (OWA):** absence of a fact does *not* make it false. RDF/OWL are open-world. + Consequence: you **cannot infer `¬trust` from the absence of a `trust` edge.** Contradiction detection + must come from *explicit* negation or *disjointness axioms* (`contradicts`), never from missing data. + This is why `polarity` + `contradicts` are first-class fields rather than something a query infers. +- **No Unique Name Assumption (no-UNA):** two different identifiers may denote the same entity unless + said otherwise. This is what makes `sameAs`, functional, and inverse-functional properties powerful + (they *infer* identity) — and dangerous (`sameAs` "smushing" can collapse the graph if mis-asserted). + A permissionless minting model lives and dies on getting this right. + +--- + +# Part II — Thirty years (plus) of knowledge-graph research + +A compressed lineage. The throughline: the field oscillates between **expressive, hand-curated, +logically rigorous** systems and **large-scale, emergent, statistically-driven** ones — and the best +modern designs (Wikidata, Knowledge Vault) fuse both. Intuition is squarely in that fusion space. + +## II.1 Pre-history: semantic networks & frames (1968–1985) +- **Semantic networks** (Quillian, 1968; Collins & Quillian) — concepts as nodes, labeled edges; the + original "meaning as graph topology." Introduced `is-a` and property inheritance. +- **Frames** (Minsky, 1974) — structured concepts with slots and defaults; the ancestor of objects, + classes, and schema.org "types." +- **Conceptual Graphs** (Sowa, 1976/1984) — logic-based graphs unifying semantic nets with predicate + logic; an early, serious attempt at *machine-readable relations with formal meaning*. +- **WordNet** (Miller, Princeton, 1985–) — a lexical graph whose *relation types themselves* are the + contribution: hypernymy/hyponymy (`is-a`, ≈ our `specializes`), meronymy (part-of, ≈ containment), + **antonymy** (≈ our `contradicts`), troponymy. WordNet is the canonical evidence that a small, + well-chosen set of relation *properties* captures most of lexical meaning. Directly validates our + approach of enriching predicates with a compact, principled property set. + +## II.2 The logicist peak: Description Logic & Cyc (1985–2000) +- **KL-ONE** (Brachman & Schmolze, 1985) — structured inheritance networks with formal subsumption; + the direct ancestor of DL and thus of OWL. Gave us `subPropertyOf`/`subClassOf` with real semantics. +- **Description Logic** matures (1990s) — `ALC` and its family, tableau reasoners (FaCT, RACER, CLASSIC), + and the systematic mapping of *which axioms cost what* (the Description Logic Handbook, Baader et al., + 2003). This is the body of theory OWL later standardized. +- **Cyc** (Lenat, 1984–) — a decades-long bet on hand-encoding common sense. Lessons: (1) **microtheories** + — truth is *context-scoped*, the same assertion can hold in one context and fail in another (a warning + for any global truth graph); (2) hand-curation does not scale to the open world. Cyc's struggles are + why the next era went statistical and emergent. + +## II.3 The Semantic Web (1999–2010) +- **The vision** (Berners-Lee, Hendler & Lassila, *Scientific American*, 2001) — a web of machine-readable + assertions. **RDF** (1999/2004), **RDFS**, then **OWL** (2004, out of the DARPA **DAML+OIL** effort, + ~2001), **SPARQL** (2008), and **OWL 2** (2009). +- **Linked Data** (Berners-Lee, 2006) and the **Linking Open Data** project operationalized "publish + triples, link by URI." The web-scale knowledge bases arrived: **DBpedia** (Auer et al., 2007), + **YAGO** (Suchanek, Kasneci & Weikum, 2007), **Freebase** (Metaweb, 2007 → Google, 2010). +- **Lesson:** rich OWL reasoning rarely ran at web scale; most deployed value came from RDFS-level + inference (hierarchies, domains) plus links. Expressivity that can't be computed is decoration. + +## II.4 "Knowledge Graph" as a term, and the pragmatic turn (2010–2015) +- **schema.org** (Google/Bing/Yahoo, 2011) — a deliberately *lightweight, shared* vocabulary that won + adoption precisely by *not* demanding heavy OWL semantics. Intuition already speaks schema.org in its + IPFS predicate documents — a direct inheritance. +- **Google Knowledge Graph** (2012) — coined the popular term; "**things, not strings**." Built on + Freebase. Marked the shift from ontology-first to entity-first at scale. +- **Wikidata** (Vrandečić & Krötzsch, 2012; Freebase's successor) — the most important design reference + for Intuition. Permissionless, collaboratively edited, and it bakes in exactly the meta-machinery we're + reaching for: **qualifiers** (context on a statement), **ranks** (preferred/normal/deprecated values), + **references** (provenance per statement), and **property constraints** (soft validation). Wikidata is + proof that an emergent, multi-actor graph still needs disciplined per-statement metadata to be usable. + +## II.5 Statistical & learned knowledge graphs (2010–2020) +- **NELL** (Carlson & Mitchell, CMU, 2010) — never-ending extraction with *confidence scores*; knowledge + as probabilistic, not binary. +- **Knowledge Vault** (Dong et al., Google, 2014) — fuses extractors with prior graphs under a + probabilistic model; **provenance and confidence are first-class**. Validates Intuition's instinct to + treat staking/attestation (signal + provenance) as core, not metadata. +- **Knowledge-graph embeddings** — and this is the striking convergence: the embedding literature + independently rediscovered *our exact property set* as the patterns a model must capture. + - **TransE** (Bordes et al., 2013) — relation as translation `h + r ≈ t`; natively models composition + (transitivity) and inversion, struggles with symmetry and 1-to-many. + - **DistMult** (Yang et al., 2015) — bilinear; models symmetry but *cannot* model antisymmetry. + - **ComplEx** (Trouillon et al., 2016) — complex embeddings specifically to capture **antisymmetry**. + - **RotatE** (Sun et al., 2019) — relation as rotation in complex space; its headline claim is that a + good representation must capture **symmetry, antisymmetry, inversion, and composition** — i.e. + `symmetric`, `asymmetric`, `inverse`, `transitive`. When a hand-built logic (OWL) and a learned + geometry (RotatE) converge on the *same four relational properties*, that set is not arbitrary — it + is the load-bearing core. Our `logic` facet is exactly these four plus cardinality. + - **R-GCN** (Schlichtkrull et al., 2018) — relational graph neural nets for link prediction. + +## II.6 Time, provenance, and statements-about-statements (the reification thread) +This thread is where Intuition's model is most distinctive, so it matters most. +- **States vs events** is old AI: situation calculus (McCarthy & Hayes, 1969) and event calculus + (Kowalski & Sergot, 1986) distinguish **fluents** (things that hold over intervals and can change) + from **events** (instantaneous). This is precisely our `temporalNature: state | event | permanent`. +- **Temporal RDF** (Gutierrez, Hurtado & Vaisman, 2007) and temporal KG embeddings added valid-time to + triples. The lesson: a `state` edge without a validity interval silently rots. +- **PROV-O** (W3C, 2013) standardized provenance (entities, activities, agents) — the vocabulary for + "who asserted this, derived from what." +- **Reification — how to say something about a statement** — is the field's longest-running headache, + and Intuition's triples-as-atoms model *is* a reification design. The options the field tried: + 1. **RDF standard reification** (1999) — a 4-triple `rdf:Statement` blob; verbose, semantically weak. + 2. **N-ary relations** — introduce an intermediary node for the relationship. + 3. **Named graphs / quads** — attach context to whole graphs. + 4. **Singleton properties** (Nguyen, Bodenreider & Sheth, 2014) — mint a unique sub-property per + statement; strikingly close to Intuition giving each predicate/edge an atom identity. + 5. **RDF-star / SPARQL-star** (Hartig, ~2014, standardized later) — first-class edge annotations; the + modern answer and the closest analog to a property-graph with per-edge metadata. +- Our `objectKind: 'claim'` (for `disputedBy`, `confirmedBy`, `attestedBy`, `citedBy`, `evidencedBy`) is + exactly this: predicates whose object is *another statement*. Naming it makes Intuition's reification + explicit and traversable instead of implicit. + +## II.7 Signed & trust networks (the social-graph thread Intuition actually lives in) +Classical KGs modeled *facts*; Intuition models *trust and opinion*, so the relevant theory is signed +social networks, which classical OWL says nothing about: +- **Structural balance theory** (Heider, 1946; formalized by Cartwright & Harary, 1956) — "the friend of + my friend is my friend; the enemy of my enemy is my friend." The original math of **signed** (positive/ + negative) edges. This is the academic backbone of our `polarity` field in a trust graph. +- **Status theory & signed-edge prediction** (Leskovec, Huttenlocher & Kleinberg, 2010, *Signed Networks + in Social Media*) — trust/distrust edges (Epinions, Slashdot) obey balance *and* status dynamics, and + signs are predictable from local structure. Direct grounding for treating `trust`/`distrust`, + `endorse`/`oppose`, `bullishOn`/`bearishOn` as signed and mutually constraining. +- **Subjective Logic** (Jøsang, ~2001) — an algebra of opinions with explicit *uncertainty*, built for + trust propagation. Relevant if Intuition ever wants to propagate/compose trust quantitatively, and a + pointer for a future `intensity`/confidence dimension. +- **Property-graph model & Cypher** (Neo4j) — the industrial counter-tradition to RDF: edges carry + arbitrary key/value properties natively (no reification needed). Intuition's per-edge economics make it + closer to a property graph than to classic RDF, which is worth keeping in mind when we choose how much + to serialize on-chain vs. compute off-graph. + +## II.8 OntoClean — meta-properties to discipline an ontology (the closest precedent to this work) +**OntoClean** (Guarino & Welty, 2000–2002) deserves its own note because it is *the* precedent for what +we are doing: tagging relations/types with **meta-properties** to keep an ontology honest. Its tags: +- **Rigidity** — is a property essential (`+R`, true of an instance in every world: *being a person*) or + a temporary role (`~R`/`-R`: *being a student/employee*)? This is almost exactly our `temporalNature` + `permanent` vs `state` distinction, arrived at from a different direction. +- **Identity** — does the property carry a criterion for telling two instances apart? (cf. our IFP/keys.) +- **Unity** and **Dependence** — is the entity a whole, and does it depend on another? (cf. relational + vs intrinsic predicates.) +OntoClean's whole thesis — *a small set of philosophical meta-properties dramatically improves an +ontology's quality and catches modeling errors* — is the precedent that says enriching predicates with +`polarity`/`claimType`/`temporalNature`/`logic` is not gold-plating; it is the known path to a clean graph. + +--- + +# Part III — Mapping the research onto Intuition's fields + +| Field (from `predicate-semantics.md`) | Primary lineage | One-line takeaway | +|---|---|---| +| `logic.symmetric / asymmetric / transitive` | OWL property characteristics; RotatE/ComplEx | The empirically-confirmed load-bearing core — both logic and ML converge here. | +| `logic.reflexive / irreflexive` | OWL 2 | Self-loop validity; respect the constraint lattice (asymmetric ⟹ irreflexive). | +| `logic.functional / inverseFunctional` | OWL; entity resolution | IFPs are *keys* — the Semantic Web's dedup mechanism, vital for permissionless minting. | +| `ontology.inverse` | `owl:inverseOf` | Typed property ref, not a string; store one direction. | +| `ontology.specializes` | RDFS `subPropertyOf`; KL-ONE; WordNet hypernymy | Cheapest high-value inference: roll-up. | +| `ontology.contradicts` | OWL 2 `propertyDisjointWith`; WordNet antonymy; balance theory | The formal hook for disagreement markets; required because OWA forbids inferring negation from absence. | +| `ontology.equivalentTo` | `owl:equivalentProperty` | Aliases only; beware no-UNA "smushing." | +| `polarity` | Signed networks (Heider; Leskovec et al.) | Classical KGs lacked this; a *trust* graph cannot. | +| `claimType` (factual/evaluative/normative) | fact/value distinction; Cyc microtheories; subjective logic | A market over a fact ≠ a market over an opinion. | +| `verifiability` | Knowledge Vault; PROV-O; Wikidata references | Provenance/confidence as first-class, validating staking-as-signal. | +| `temporalNature` | situation/event calculus; temporal RDF; OntoClean rigidity | Distinguishes edges that rot from edges that are permanent. | +| `objectKind` (entity/claim/literal) | Object vs datatype properties; reification thread (singleton properties, RDF-star) | Makes Intuition's triples-as-atoms reification explicit and traversable. | +| `marketPattern` | Intuition-native (no classical analog) | The economic layer the prior 30 years never had. | + +--- + +# Part IV — What the research warns us about + +1. **Don't out-reason your compute (the OWL species lesson).** Pick a target profile. OWL 2 RL — + property hierarchy + inverse + symmetry + transitivity + disjointness, all forward-chainable — buys + ~90% of the value cheaply. Treat property chains and full equality reasoning as opt-in P2. + +2. **Open World means contradictions need explicit axioms.** You can never conclude `¬trust` from a + missing edge. `polarity` and `contradicts` must be declared, not inferred from absence. Bake this into + how the disagreement/market logic reads the graph. + +3. **No Unique Names makes `sameAs`/functional/IFP both powerful and dangerous.** Identity inference can + collapse distinct entities ("smushing") if mis-asserted. Gate equivalence and key-like predicates + carefully — in a permissionless system this is a primary attack surface. + +4. **Emergent graphs still need per-statement discipline (the Wikidata lesson).** Wikidata is + permissionless *and* rigorous because every statement can carry qualifiers, rank, and references. + Intuition's staking is the analog of references; we should make sure `temporalNature`/`claimType` + give authors the same per-edge expressiveness Wikidata found indispensable. + +5. **Validation ≠ entailment (the SHACL lesson).** OWL `domain`/`range` *infer* types; they don't reject + bad data. Since Intuition wants to *reject* malformed edges, typing/validation belongs in the + classifications/SHACL-style layer — which is exactly where the design leaves it. Don't accidentally + re-import OWL's inferential domain/range semantics into a validation context. + +6. **Truth can be context-scoped (the Cyc microtheory lesson).** A predicate's assertion may be true in + one context and not another. We're not adding context-scoping now, but the design should not assume + global, timeless truth so hard that adding context later requires a rewrite. + +--- + +## Selected references + +- Quillian (1968) *Semantic Memory*; Minsky (1974) *A Framework for Representing Knowledge*; Sowa (1984) *Conceptual Structures*. +- Brachman & Schmolze (1985) *KL-ONE*; Baader et al. (2003) *The Description Logic Handbook*. +- Miller (1995) *WordNet*; Lenat (1995) *Cyc*. +- Berners-Lee, Hendler & Lassila (2001) *The Semantic Web*; W3C RDF (2004), RDFS (2004), OWL (2004), OWL 2 (2009); Horrocks, Kutz & Sattler (2006) *SROIQ*. +- Guarino & Welty (2002) *OntoClean*. +- Auer et al. (2007) *DBpedia*; Suchanek et al. (2007) *YAGO*; Vrandečić & Krötzsch (2014) *Wikidata*. +- Carlson et al. (2010) *NELL*; Dong et al. (2014) *Knowledge Vault*. +- Bordes et al. (2013) *TransE*; Yang et al. (2015) *DistMult*; Trouillon et al. (2016) *ComplEx*; Sun et al. (2019) *RotatE*; Schlichtkrull et al. (2018) *R-GCN*. +- McCarthy & Hayes (1969) *Situation Calculus*; Kowalski & Sergot (1986) *Event Calculus*; Gutierrez et al. (2007) *Temporal RDF*; W3C PROV-O (2013). +- Heider (1946); Cartwright & Harary (1956) *Structural Balance*; Leskovec, Huttenlocher & Kleinberg (2010) *Signed Networks in Social Media*; Jøsang (2001) *Subjective Logic*. +- Nguyen, Bodenreider & Sheth (2014) *Singleton Property*; Hartig (2017) *RDF-star*. diff --git a/.planning/predicate-semantics.md b/.planning/predicate-semantics.md new file mode 100644 index 0000000..2f951ea --- /dev/null +++ b/.planning/predicate-semantics.md @@ -0,0 +1,290 @@ +# Design: Machine-Readable Predicate Semantics + +**Status:** Background map — superseded on specifics by the decision record +**Author:** Architecture +**Date:** 2026-06-30 +**Scope:** `packages/predicates/src/types.ts`, `record.ts`, `ipfs.ts`, and the 133 specs under `src/generated/specs/` + +> **Reading note.** This is the original full brainstorm — the *map of what's possible*. For the **final, +> pruned field set and the flat schema shape**, `predicate-spec-decisions.md` is canonical and overrides +> §5 below (which still shows the earlier nested sketch and the full unpruned field list). Start with +> `00-START-HERE.md`. + +--- + +## 1. Motivation + +Predicates are how edges are created in the Intuition knowledge graph. Each predicate +(`affiliatedWith`, `memberOf`, `listedIn`, `trust`, `betterThan`, …) says something precise +about the relationship between its subject and object. Today most of that meaning lives in +prose (`description`) and a handful of booleans. A machine cannot reason over prose. + +This document maps out the full set of **machine-readable fields** we should add to predicate +specs so that software can: + +1. **Entail** — infer edges that were never explicitly written. +2. **Check consistency** — detect contradictory claims. +3. **Traverse & aggregate** — compute reachability, paths, and signed-signal reputation. +4. **Validate** — reject malformed edges at authoring time. +5. **Interpret** — render, price (markets), weigh sentiment, and judge freshness. + +A field earns a place in the schema only if a machine can *do* one of those five things with +it. Anything else is documentation and stays in `description`/`examples`. + +We deliberately adopt the vocabulary of **OWL / RDF property characteristics** (symmetric, +transitive, functional, inverse, sub-property, disjoint, …). That formal system was built for +exactly this problem, so aligning with it gives us interoperability with existing reasoners +and with schema.org for free. + +--- + +## 2. Current state + +`PredicateSpec` (in `types.ts`) carries: + +| Field | Facet | Notes | +|---|---|---| +| `key`, `name`, `description` | identity / display | | +| `category`, `status` | classification / lifecycle | | +| `marketPattern` | economic | `depositional \| attributive \| comparative` | +| `conjugates` | **grammar/display** | inflection across locales — *not* a relational property | +| `thirdPerson`, `examples` | display | | +| `isSymmetric`, `isTransitive`, `isHierarchical` | algebraic | partial coverage | +| `inversePredicate` | ontological | **a display-name string, not a typed key** | + +Domain/range typing intentionally lives in the `@0xintuition/classifications` predicate matrix; +`entity-type-map.ts` is explicitly legacy. **This design does not duplicate typing into specs.** + +### Naming issue to fix first + +The user's original prototypes were *symmetric*, *conjugative*, *transitive*. Two are clean. +"Conjugative" is ambiguous and should be retired: + +- `conjugates: boolean` in code is a **grammar** concept (it sits next to `i18n`/`thirdPerson` + in the IPFS serializer) — "this verb inflects across locales." +- The *relational* idea behind "conjugative" — "this relationship has a converse expressed by a + **different** predicate" — is already `inversePredicate` (`parentOrganization ⇄ subOrganization`). + +**Decision:** drop "conjugative" as a relational term. Keep `inverse` for the converse relation. +Rename the grammar field to `inflects` and move it under a `grammar` facet, removing the collision. + +--- + +## 3. Facet model + +We organize fields into facets rather than a flat blob. Most specs will only populate a few. + +| Facet | Job(s) | Field family | +|---|---|---| +| **Algebraic** | entailment, consistency, validation | `logic { symmetric, asymmetric, transitive, reflexive, irreflexive, functional, inverseFunctional }` | +| **Ontological** | entailment, consistency | `ontology { inverse, specializes, equivalentTo, contradicts }` | +| **Polarity** | aggregation, interpretation | `polarity` | +| **Epistemic** | interpretation, consistency | `claimType`, `verifiability` | +| **Temporal** | interpretation, freshness | `temporalNature` | +| **Target** | validation, traversal | `objectKind` | +| **Economic** | interpretation | `marketPattern` (existing) | +| **Presentation** | rendering only | `grammar { inflects, thirdPerson }`, `examples`, i18n | + +--- + +## 4. Field reference + +### 4.1 Algebraic (`logic`) + +OWL property characteristics. Cheap booleans that unlock automatic edge inference. + +| Field | Meaning | Powers | Examples | +|---|---|---|---| +| `symmetric` | R(a,b) ⟹ R(b,a) | store one direction, query both; synthesize reciprocal edges | `affiliatedWith`, `partnerOf`, `competeWith`, `sameAs` | +| `asymmetric` | R(a,b) ⟹ ¬R(b,a) (implies irreflexive) | contradiction detection | `parentOrganization`, `betterThan`, `founder`, `predecessorOf` | +| `transitive` | R(a,b) ∧ R(b,c) ⟹ R(a,c) | reachability, containment trees, ranking chains | `containedInPlace`, `parentOrganization`, `rankedAbove`, `dependOn` | +| `reflexive` / `irreflexive` | R(a,a) always / never | self-loop validation | `sameAs` reflexive; nearly all else irreflexive | +| `functional` | subject → ≤1 object | uniqueness validation | `manufacturer`, `primaryImageOfPage`, `parentOrganization` | +| `inverseFunctional` | object → ≤1 subject | identity resolution (key constraint) | `linkedAccount`, `sameAs` | + +**`isHierarchical` is redundant** — derivable from `transitive + asymmetric + has-inverse`. Drop it +as a primitive; if a tree-UI hint is wanted, document it as derived, not authored. + +**Payoff:** with `symmetric` + `inverse`, reciprocal edges are never stored — the graph layer +synthesizes them, halving writes for bidirectional predicates and making queries direction-agnostic. + +### 4.2 Ontological (`ontology`) — predicates relating to predicates + +The highest-leverage *new* facet. Predicates form their own graph. + +| Field | Meaning | Powers | Example | +|---|---|---|---| +| `inverse` (typed `PredicateKey`) | converse predicate | bidirectional query | `parentOrganization` → `subOrganization` | +| `specializes` (subPropertyOf) | this edge implies a broader edge | **roll-up reasoning** | `employedBy`, `alumniOf`, `memberOf` ⊑ `affiliatedWith` | +| `equivalentTo` | predicate-level alias | dedup | curate, e.g. `createdBy` ≡ `authoredBy`? | +| `contradicts` (disjointWith) | both cannot hold for same (s,o) | **contradiction / disagreement markets** | `trust` ⊥ `distrust`; `bullishOn` ⊥ `bearishOn`; `support` ⊥ `oppose` | + +> **Typed refs, not strings.** `inversePredicate: 'sub organization'` today is a display name that can +> drift from the `subOrganization` key. All ontological references must be **typed `PredicateKey`s**, +> so they are validatable and `definePredicateRecord` can cross-check that inverse pairs mirror each +> other's algebraic properties. + +`specializes` and `contradicts` are the two to ship first (equal priority). `specializes` lets a query +for "is X affiliated with Y" automatically catch `employedBy`/`alumniOf`/`memberOf`. `contradicts` +turns the trust graph into something that can detect and price disagreement. + +### 4.3 Polarity (`polarity`) + +``` +polarity: 'positive' | 'negative' | 'neutral' | 'none' +``` + +- positive: `endorse`, `trust`, `vouchFor`, `support`, `bullishOn`, `like`, `recommend` +- negative: `oppose`, `distrust`, `bearishOn`, `blocked`, `skepticalOf`, `disagreeWith`, `reported` +- neutral: `neutralOn` +- none: `manufacturer`, `containedInPlace`, `authoredBy` (relational, no sentiment) + +Lets a reputation engine aggregate **signed signal across heterogeneous predicates** into one score, +instead of hard-coding the sign of each predicate in the scoring layer. (`intensity: weak | strong` +is a deferred follow-up.) + +### 4.4 Epistemic (`claimType`, `verifiability`) + +``` +claimType: 'factual' | 'evaluative' | 'normative' +verifiability: 'onChain' | 'offChainVerifiable' | 'unverifiable' +``` + +- `factual`/verifiable: `manufacturer`, `authoredBy`, `parentOrganization`, `linkedAccount` — staking is *truth discovery*. +- `evaluative` (subjective): `betterThan`, `like`, `bullishOn` — staking is *opinion aggregation*. +- `normative` (ought): `compliantWith`, `regulatedBy`, `governedBy`. + +A market over a fact and a market over an opinion mean different things; economics and UI should +treat them differently. The schema currently cannot tell them apart. + +### 4.5 Temporal (`temporalNature`) + +``` +temporalNature: 'permanent' | 'state' | 'event' +``` + +- `permanent` — immutable once true; never stale: `authoredBy`, `derivedFrom`, `founded`, `precededBy`. +- `state` — durative, can silently become false; **needs validity intervals / "current"**: `employedBy`, `memberOf`, `locatedIn`, `governedBy`. +- `event` — point-in-time fact, stays true as history: `votedFor`, `reviewed`, `attestedBy`. + +Tells the graph layer which edges can rot (need expiry / re-attestation) versus write-once. + +### 4.6 Target (`objectKind`) + +``` +objectKind: 'entity' | 'claim' | 'literal' +``` + +- `literal` — object is a value, do not traverse: `url`, `imgUrl`, `hasDescription`. +- `claim` (reifying / meta-relation) — points at another statement, not an entity: `disputedBy`, `confirmedBy`, `attestedBy`, `citedBy`, `evidencedBy`. +- `entity` — the default majority. + +A validation + traversal primitive, and our reification story made explicit. + +### 4.7 Domain / range — intentionally excluded + +Typed subject/object targets remain in `@0xintuition/classifications`. Do **not** add +`subjectType`/`objectType` to specs; two sources of truth would drift. An optional non-authoritative +schema.org `domain`/`range` hint for export is deferred. + +--- + +## 5. Proposed schema shape (hybrid nested) — SUPERSEDED + +> ⚠️ **Superseded by `predicate-spec-decisions.md` §4–5.** The team decided **flat, not nested** +> (the serialization target and the entire OWL/RDF tradition are flat), and pruned the field set. +> The nested sketch below is kept only to show the reasoning we moved past. Use the decision record's +> flat `PredicateSpec` as the source of truth. + +Decision: keep small enum semantic fields flat (they read well and serialize trivially) and group +the two field-rich families (`logic`, `ontology`) plus presentation (`grammar`) into nested objects +so the type stays scannable. Retain `as const satisfies PredicateSpec`. + +```ts +export interface PredicateSpec { + key: string; + name: string; + description: string; + category: PredicateCategory; + status: PredicateStatus; + + // economic (Intuition) + marketPattern: MarketPattern; + + // semantics (flat enums) + polarity?: 'positive' | 'negative' | 'neutral' | 'none'; + claimType?: 'factual' | 'evaluative' | 'normative'; + verifiability?: 'onChain' | 'offChainVerifiable' | 'unverifiable'; + temporalNature?: 'permanent' | 'state' | 'event'; + objectKind?: 'entity' | 'claim' | 'literal'; + + // algebraic + logic?: { + symmetric?: boolean; + asymmetric?: boolean; + transitive?: boolean; + reflexive?: boolean; + irreflexive?: boolean; + functional?: boolean; + inverseFunctional?: boolean; + }; + + // ontological (typed key refs) + ontology?: { + inverse?: PredicateKey; + specializes?: PredicateKey; + equivalentTo?: PredicateKey; + contradicts?: readonly PredicateKey[]; + }; + + // presentation (rendering only) + grammar?: { inflects?: boolean; thirdPerson?: string }; + examples?: readonly string[]; +} +``` + +`definePredicateRecord` becomes the **derivation + validation layer**: + +- normalize defaults, +- derive implied properties (`asymmetric ⟹ irreflexive`; `symmetric ⟹ inverse = self`; + `transitive ⟹ inverse is transitive`), +- cross-validate that inverse pairs mirror each other's algebraic properties and that + `contradicts` is symmetric across the pair. + +This keeps authoring terse while the materialized record stays complete and internally consistent. + +--- + +## 6. Rollout plan + +All new fields are optional ⟹ additive and non-breaking. The only real refactor is +`inversePredicate: string` → `ontology.inverse: PredicateKey`. + +**P0 — adopt now (mostly mechanical backfill across 133 specs):** +- `polarity`, `temporalNature`, `objectKind` +- `logic.{symmetric, asymmetric, transitive, irreflexive}` (migrate existing booleans in) +- convert `inverse` to a typed `PredicateKey` + +**P1 — next (human curation; highest reasoning leverage):** +- `ontology.specializes` **and** `ontology.contradicts` (curated together, equal priority) +- `claimType` +- derivation/validation engine in `definePredicateRecord` +- extend the IPFS `additionalProperty` serializer + `PredicateIpfsPropertyName` union + +**P2 — research / future:** +- `functional`/`inverseFunctional`, `equivalentTo`, `verifiability`, `intensity` +- OWL property-chain axioms (`grandparentOf` from `parentOf ∘ parentOf`) +- a standalone `@0xintuition/reasoning` module consuming these for live entailment/consistency + +--- + +## 7. Open questions + +1. **`equivalentTo` policy** — do we collapse near-synonyms (`createdBy`/`authoredBy`) at the + predicate level, or keep them distinct and only relate them? Affects atom-id stability. +2. **Inverse coverage** — do all directional predicates get a named inverse, or only where the + converse is independently useful? (Synthesizing an unnamed inverse vs. minting a predicate.) +3. **`contradicts` strength** — hard logical contradiction (`trust`/`distrust`) vs. soft tension + (`skepticalOf`/`trust`). Do we need a separate `tension` relation, or is one field enough? +4. **Serialization surface** — which of these belong in the on-chain/IPFS predicate document vs. + stay package-side metadata for the reasoning layer? diff --git a/.planning/predicate-spec-audit.md b/.planning/predicate-spec-audit.md new file mode 100644 index 0000000..21c1d46 --- /dev/null +++ b/.planning/predicate-spec-audit.md @@ -0,0 +1,288 @@ +# Audit: Predicate Spec Review Against 30 Years of KG Research + +**Status:** ✅ Applied — all findings (A1–D5) folded into the planning docs on 2026-07-01; this file +remains as the record of *why* each change was made +**Audits:** `predicate-spec-decisions.md` (canonical), `predicate-fields/*` (scores), backend docs +**Date:** 2026-07-01 + +This is the adversarial pass: take the proposed field set and structure, and stress-test it against the +research record (OWL 2 semantics, RDFS entailment rules, Wikidata's operational lessons, signed-network +theory, the identity/smushing literature) *and* against Intuition's own mechanics (content-addressed IDs, +staking, counter-triples). Findings are ordered by severity. §E lists what survives unchanged — which is +most of it. + +--- + +## A. Must resolve before shipping (semantic or economic correctness) + +### A1 — Symmetric/inverse predicates + content-addressed IDs = duplicate facts with split stake + +**The finding.** The entire OWL tradition assumes a triple store where synthesizing a reverse edge is a +pure inference: cheap, idempotent, no side effects. Intuition triples are different — each triple has a +**deterministic ID and its own vault/market**. That creates a failure mode OWL never had: + +- `⟨Acme, partnerOf, BigCo⟩` and `⟨BigCo, partnerOf, Acme⟩` are the *same fact* but hash to **two + different triple IDs → two different markets**. Stake splits across them; neither reflects true conviction. +- Same for inverse pairs: `⟨Alice, employedBy, Acme⟩` vs `⟨Acme, employs, Alice⟩` — one fact, two + mintable triples, two vaults. + +The current docs handle the *read* side (indexer synthesizes reverse edges, marks them `inferred`) but +not the *write/mint* side. Nothing stops two users from independently minting both directions. + +**The research precedent.** Wikidata hit exactly this operationally: inverse property pairs (e.g. +child/parent-style pairs) required permanent bot armies to keep both directions in sync, and the community's +considered response was to **avoid inverse properties and designate one canonical direction** wherever +possible. We should learn this before we ship, not after. + +**Recommendation (two layers):** +1. **Mint-time canonicalization in `primitives`.** For `isSymmetric` predicates, canonically order + subject/object (e.g., by atom ID) before computing the triple ID — both user intents resolve to one + triple, one market. For `inverse` pairs, designate a **canonical direction** per pair (a derived flag in + `definePredicateRecord`, e.g., the non-inverse member or lexicographically smaller key) and have + builders normalize to it: a user asserting the non-canonical direction gets the canonical triple. +2. **Indexer-side merge as backstop** for triples minted before the rule (or via raw protocol calls): + detect symmetric/inverse duplicates and link them (the `sibling_triple_id` pattern already exists for + counter-triples; this is a second, analogous linkage), with an explicit policy for how their stake is + presented in aggregate. + +This is the one place the proposal is not just incomplete but **economically unsound** if skipped: edge +synthesis without mint canonicalization actively encourages liquidity fragmentation. +**Owner surface:** `primitives` + `ids` (canonicalization), backend (merge backstop), decision record §5. + +### A2 — `contradicts` conflates two different kinds of conflict + +**The finding.** The docs use two incompatible notions of contradiction interchangeably: + +1. **Pair-level logical disjointness** (OWL `propertyDisjointWith`): `⟨s, trust, o⟩` and `⟨s, distrust, o⟩` + with the **same subject and object** cannot both hold. This is what `contradicts` formally is, and what + `contradicts.md` defines ("cannot both hold for the same subject/object pair"). +2. **Attester-level disagreement**: *"Alice asserts trust Bob; Carol asserts distrust Bob"* (decision + record P3, `contradicts.md` example 1). If the triples are `⟨Alice, trust, Bob⟩` and + `⟨Carol, distrust, Bob⟩`, the **subjects differ** — there is no logical contradiction at all. Two people + disagreeing about Bob is not incoherence; it's the graph working as intended. + +These need different machinery. Pair-level disjointness is a consistency/validation signal (and the correct +trigger for a *single* conflict market: same s/o pair, disjoint predicates). Attester-level disagreement +about an object is a **sentiment-aggregation** question already served by `polarity` (signed sum over +edges into Bob) plus the existing counter-triple mechanism. + +**Recommendation:** Define `contradicts` strictly as pair-level disjointness (matching OWL and +`contradicts.md`'s own formal definition). Rewrite the P3 user story so the example shares a subject — +or explicitly shows both layers: pair-level conflict routes to one market; object-level disagreement is a +polarity aggregate. This is a documentation fix, but it determines what the indexer's conflict-detection +query actually joins on, so it must be settled before WS-D. **Owner surface:** decision record P3, +`contradicts.md`, backend conflict-detection spec. + +### A3 — `objectKind: 'literal'` can't fulfill the rendering contract without a datatype + +**The finding.** `objectKind` is our highest-scored field (92) on the promise that the frontend renders any +edge with zero per-predicate code. But its own flagship example betrays a gap: *"`literal` → render +`imgUrl` as an ``, `url` as an ``"* — that switch is back on the **predicate key**, not on +`objectKind`. Knowing "it's a literal" doesn't tell you image vs link vs date vs number vs prose. RDF +solved exactly this with **typed literals** (`xsd:anyURI`, `xsd:date`, …); schema.org with `rangeIncludes` +datatypes. Without a datatype, the "giant switch" the field promises to delete survives for the literal +subset — which is precisely the subset with the most rendering variance. + +**Recommendation:** Add a companion field, only meaningful when `objectKind === 'literal'`: + +```ts +literalType?: 'url' | 'image' | 'date' | 'number' | 'text'; +``` + +Validated in `definePredicateRecord` (`literalType` present ⟹ `objectKind === 'literal'`). It scores like +`objectKind` itself: frontend consumer today, mechanical to author (the literal predicate set is small), +non-redundant, serializes cleanly to XSD datatypes. Estimated score under the rubric: **~85 (Ship)**. +Alternative if we want to stay minimal: document that literal rendering falls back to schema.org property +types via `classifications` — but that re-introduces a cross-package lookup in the hot rendering path, +which the rendering-contract argument was built to avoid. **Owner surface:** types, decision record §5, +new `predicate-fields/literal-type.md`. + +--- + +## B. Structural decisions — cheap now, painful later + +### B1 — `specializes` should be an array (DAG), not a single key (tree) + +RDFS `subPropertyOf`, WordNet hypernymy, and schema.org all permit **multiple broader terms** — property +hierarchies are DAGs, not trees. Real cases hit this immediately: `founder` specializes `affiliatedWith` +*and* plausibly a creation/contribution relation; `authoredBy` sits under both `contributedTo` and a +provenance relation. A single `PredicateKey` forces false choices, and widening the type later is a +migration; widening it now is free. It also makes the shape consistent with `contradicts` +(`readonly PredicateKey[]`). + +**Recommendation:** `specializes?: readonly PredicateKey[]`. Keep the curation guidance "usually one +parent, never more than two" as a lint warning, not a type constraint. + +### B2 — Enumerate the full validation/derivation rule set for `definePredicateRecord` + +The decision record names three rules (symmetric ⟹ self-inverse; inverse mirroring; contradicts declared +symmetrically). The DL literature gives us the **complete** list for our field set — and half of it is +missing. The gate should enforce: + +| # | Rule | Kind | Source | +|---|---|---|---| +| 1 | `isSymmetric` ⟹ no distinct `inverse` (inverse = self) | error | OWL (already specified) | +| 2 | `P.inverse = Q ⟺ Q.inverse = P` | error | OWL (already specified) | +| 3 | `contradicts` symmetric: `B ∈ A.contradicts ⟺ A ∈ B.contradicts` | error | OWL (already specified) | +| 4 | `contradicts` irreflexive: `P ∉ P.contradicts` | error | coherence | +| 5 | `specializes` acyclic (no `P ⊑ … ⊑ P`) | error | RDFS coherence | +| 6 | P may not contradict its own `specializes` ancestor/descendant (asserting P entails the ancestor — contradiction with it is incoherent) | error | DL: `P ⊑ Q` ∧ `P ⊥ Q` ⟹ P unsatisfiable | +| 7 | **Derive** contradiction closure down the hierarchy: `P ⊑ Q` ∧ `Q ⊥ R` ⟹ `P ⊥ R` — expand at build time so consumers get the full disjointness set without walking `specializes` at query time | derivation | OWL 2 RL | +| 8 | Inverse pairs mirror algebra: `P.isTransitive ⟺ inverse(P).isTransitive` (theorem: the inverse of a transitive relation is transitive); same `temporalNature`; same `claimType`; polarity equal (lint, not error — near-universal but conceivable exceptions) | error/lint | DL theorems | +| 9 | `isSymmetric && isTransitive` together ⟹ the predicate behaves as an **equivalence relation** — allowed only for an explicit allow-list (`sameAs`); warn otherwise | lint | see C2 | +| 10 | `polarity` present (sentiment predicate) ∧ `isTransitive` ⟹ **error** | error | see C1 | +| 11 | `isTransitive` is **not** inherited via `specializes` (a sub-property of a transitive property is not transitive — RDFS/OWL) — document so the indexer never expands closure over sub-properties | doc/derivation guard | OWL semantics | +| 12 | `literalType` present ⟹ `objectKind === 'literal'` (if A3 accepted) | error | — | +| 13 | `objectKind === 'claim'` ∧ `inverse` present ⟹ warn (the inverse's *subject* would be a claim — expressible but almost always a modeling smell) | lint | — | + +Rules 6, 7, and 11 are the ones most likely to cause silent bugs if unenforced: 6 and 7 because +`contradicts` × `specializes` interaction is invisible until a conflict query misses (`vouchFor ⊑ trust`, +`trust ⊥ distrust` — a `vouchFor`/`distrust` pair on the same s/o *is* a conflict and won't be detected +without closure); 11 because "transitive-ish inheritance" is an extremely natural indexer bug to write. + +### B3 — Ship the explicit serialization mapping table (P6 is asserted, not specified) + +The decision record claims interoperability "for free" via OWL-aligned flat naming, but no doc pins the +actual field↔IRI mapping. Make it normative: + +| Spec field | Serialized as | Standard | +|---|---|---| +| `isSymmetric: true` | `rdf:type owl:SymmetricProperty` | OWL 2 | +| `isTransitive: true` | `rdf:type owl:TransitiveProperty` | OWL 2 | +| `inverse` | `owl:inverseOf` | OWL 2 | +| `specializes` | `rdfs:subPropertyOf` | RDFS | +| `contradicts` | `owl:propertyDisjointWith` | OWL 2 | +| `literalType` (if accepted) | XSD datatype on range (`xsd:anyURI`, `xsd:date`, …) | RDF/XSD | +| `objectKind` / `polarity` / `temporalNature` / `claimType` / `marketPattern` | Intuition-namespaced `PropertyValue` entries (no standard equivalent — that's fine; they're our pragmatic extensions, like Wikidata's own property metadata) | — | + +Third parties get standards-compliant characteristics without bespoke parsing only if this table is +implemented exactly; otherwise P6 is aspiration. + +--- + +## C. Guardrails the literature insists on (encode as lint rules + doc warnings) + +### C1 — Sentiment is never transitive + +Guha et al. (2004), *Propagation of Trust and Distrust* — the canonical result: trust propagation decays +sharply with path length, and **distrust does not propagate transitively at all** (one-step only; two +"distrust" hops do not imply distrust — arguably the opposite, per structural balance). Marking any +polarity-bearing predicate `isTransitive` would let the indexer synthesize edges the theory says are +false, and at graph scale, closure over a social predicate is also a combinatorial explosion. +`isTransitive` belongs to **structural containment** (`locatedIn`, `partOf`, `subOrganizationOf`) and +`sameAs` — nothing social. This is validation rule B2-#10, and it deserves a visible warning in +`is-transitive.md` because it's the single most tempting authoring mistake ("if Alice trusts Bob and Bob +trusts Carol…" is exactly the intuition the research debunks). + +### C2 — The equivalence engine keys off an explicit `sameAs` allow-list, not inferred algebra + +`isSymmetric + isTransitive` mathematically characterizes an equivalence-like relation, and it's tempting +to have the identity engine (query-optimization spec §5) treat *any* such predicate as identity-bearing. +Don't. The no-UNA "smushing" literature (foundations doc, Part IV) is unambiguous: **identity merge is the +highest-blast-radius inference in the system** — one bad `sameAs` edge already merges two entities' +metadata, stake context, and reputation; letting the behavior attach to an algebraic pattern rather than a +named predicate means a future author can create it *by accident* with two booleans. Equivalence-class +membership must be opt-in by predicate key (today: `sameAs` only), with the symmetric+transitive +combination elsewhere triggering lint (B2-#9). + +### C3 — Inferred edges need a confidence-decay policy + +`kg.triples.confidence` exists, and transitive closure will write inferred edges derived from chains. The +probabilistic-KG literature (PSL, Markov logic, Google Knowledge Vault) is consistent: chained inference +compounds uncertainty — an inferred edge's confidence must be a **non-increasing function of the chain** +(min or product of constituent confidences; min is the conservative standard choice), never a copy of one +constituent and never 1.0. Also: inferred edges must **not feed further rounds of inference across +different rules** without depth bounds (materialization loops). One paragraph in the backend +implementation plan (WS-D) settles this; its absence is how "everything is 100% confident" graphs happen. + +--- + +## D. Worth considering (small, non-blocking) + +### D1 — `supersededBy?: PredicateKey` on deprecated predicates + +`status: 'deprecated'` is a dead end today — nothing tells a picker, a renderer of old triples, or a +migration script *what replaced it*. Wikidata's "replaced by" is the precedent, and with 133 community- +evolving specs, deprecation-with-forwarding will be routine. Cheap (one optional key, validated to point +at a non-deprecated spec), real consumer (pickers hide-and-redirect; old edges render with a "superseded" +affordance). Estimated score: **~70 (Soft-ship)**. Natural companion to the lifecycle fields we already have. + +### D2 — Roadmap note: the predicate metadata should eventually live *in the graph* + +Wikidata models property metadata as ordinary statements **on the property entities themselves** — which +is why its community can extend and contest property semantics without a software release. Our metadata +lives in a TS package + IPFS docs — right for now (deterministic, reviewable, ships with the code). But +the end-state for a permissionless knowledge graph is **self-description**: `⟨trust, contradicts, distrust⟩` +as an actual triple that can itself be staked on. The current design is compatible with that future +(predicates are atoms; the meta-relations are just more predicates) — state it explicitly in the decision +record §7 so nobody designs it out, and so "the community disagrees with our `contradicts` curation" has +an eventual on-protocol answer rather than a GitHub issue. + +### D3 — Acknowledge the P3 asymmetry hole explicitly + +With `isAsymmetric` deferred, the P3 story's second example — rejecting *"A better than B"* alongside +*"B better than A"* — has **no shipped mechanism**: that incoherence is converse-pair (`⟨s,P,o⟩` vs +`⟨o,P,s⟩`), which `contradicts` (same-pair, different-predicate) cannot express. The deferral is still +right (score 54 stands; no validation engine exists), but the decision record should stop implying P3 +covers it. Amend the story, and note in `is-asymmetric.md` that comparative-predicate coherence is what +graduates it. (If A1's mint-time canonicalization lands, the machinery for direction-aware handling will +exist and make `isAsymmetric` cheaper to activate later — the two findings are related.) + +### D4 — Define `polarity`'s direction of aggregation precisely + +One sentence prevents a class of reputation bugs: **polarity is the sign of the edge from subject toward +object; signed aggregation accrues to the *object*.** (Heider/Leskovec signed-network convention.) So +`reported` is negative *about the thing reported*, not about the reporter. Without the sentence, someone +will eventually aggregate signal onto subjects, and the two implementations will disagree. Also specify +whether inverse pairs share polarity (per B2-#8: yes, lint-enforced) — for `trustedBy`, the aggregation +target flips to the *subject*, which the indexer must handle by normalizing through the canonical direction +(ties back to A1). + +### D5 — Policy for mixed-kind objects on `objectKind` + +schema.org's `rangeIncludes` is famously multi-valued, and a few of our predicates will have objects that +are legitimately entity-or-literal (`hasSource`: URL or an entity). Single-value enum is still right +(the rendering contract needs one answer), but state the tie-break policy: assign the **dominant** kind; +if genuinely mixed, leave the field absent (absent = "sniff the atom," today's behavior) rather than +guessing. One paragraph in `object-kind.md`. + +--- + +## E. What survives the audit unchanged (confirmations) + +- **Flat structure** — reconfirmed. Every argument in decisions §4 holds; nothing found favors nesting. +- **The pruning bar and all cuts/deferrals** — reconfirmed field by field. `isHierarchical` (redundant), + `equivalentTo` (smushing — reinforced by C2), `verifiability`, property chains (still the most expensive + axiom with no consumer), reflexivity, the functional family: all correct calls. `isAsymmetric` at Defer + is consistent (with the D3 documentation caveat). No cut field needs resurrection. +- **OWL 2 RL scoping** — the shipped algebra (symmetric, transitive, inverse, subPropertyOf, disjointness) + is exactly the forward-chainable core; still the right ceiling. +- **`claimType` soft-ship, `polarity` with explicit `neutral`, `temporalNature`'s 3 values** — all hold. + (`temporalNature: 'state'` remains the correct future hook for since/until qualifiers via reification — + no change needed now.) +- **The scoring rubric and bands** — no score changed by more than noise except where new fields are + proposed (A3's `literalType` ~85, D1's `supersededBy` ~70). +- **`definePredicateRecord` as the single consistency gate** — right pattern; B2 just completes its rule set. +- **The differentiator thesis** — `contradicts` + `polarity` + `marketPattern` as a *priced-disagreement* + graph survives scrutiny; A2 sharpens it rather than weakening it. + +--- + +## Summary of proposed changes + +| # | Change | Severity | Touches | +|---|---|---|---| +| A1 | Mint-time canonicalization for symmetric/inverse triples + merge backstop | **Blocking** (economic) | primitives, ids, backend, decisions §5 | +| A2 | Define `contradicts` as pair-level; fix P3 story; split attester-disagreement into polarity aggregation | **Blocking** (semantic) | decisions P3, contradicts.md, backend WS-D | +| A3 | Add `literalType` companion field (~85, Ship) | **High** | types, decisions §5, new field doc | +| B1 | `specializes` → `readonly PredicateKey[]` | High (cheap now) | types, decisions §5, specializes.md | +| B2 | Complete validation rule set (13 rules) in `definePredicateRecord` | High | record.ts spec, decisions §5 | +| B3 | Normative field↔IRI serialization table | Medium | decisions §2/P6 | +| C1–C3 | Lint rules + doc warnings (no transitive sentiment; sameAs allow-list; confidence decay) | Medium | field docs, backend plan | +| D1 | `supersededBy` (~70, Soft-ship) | Low | types, decisions | +| D2–D5 | Doc clarifications (self-description roadmap, P3 hole, polarity direction, mixed objectKind) | Low | various docs | + +Net effect on the field set: **+1 ship** (`literalType`), **+1 soft-ship** (`supersededBy`), **1 type +widening** (`specializes` array), **0 removals**. The shape and philosophy hold; the fixes are about +Intuition-specific mechanics (A1, A2) and completing the consistency layer (B2) — exactly the places +where our design departs from the classical literature and therefore couldn't lean on it. diff --git a/.planning/predicate-spec-decisions.md b/.planning/predicate-spec-decisions.md new file mode 100644 index 0000000..4d5014d --- /dev/null +++ b/.planning/predicate-spec-decisions.md @@ -0,0 +1,331 @@ +# Decisions: Pruning & Structuring the Predicate Spec + +**Status:** Decision record — supersedes the schema shape in `predicate-semantics.md` §5 +**Companion to:** `predicate-semantics.md` (the map), `predicate-semantics-foundations.md` (the research) +**Date:** 2026-06-30 + +The previous two docs asked *what could we add?* This one asks the harder question: *what should we +add, and what should we cut?* The rule is strict — **a field must name a consumer and a scenario, or it +does not ship.** "A future reasoner might want it" is not a scenario. + +--- + +## 1. The pruning principle: prefer fields with a consumer that exists today + +We have four consumers, in descending order of how real they are right now: + +| Consumer | Exists today? | Reads predicate fields to… | +|---|---|---| +| **Frontend** (pickers, edge cards, profiles) | ✅ yes | decide how to render an edge and its object | +| **Indexer / graph layer** | ✅ yes | synthesize reciprocal edges, close transitive queries | +| **Reputation / scoring** | 🟡 emerging | aggregate signed signal, roll up sub-relations | +| **General reasoning engine** | ❌ not built | entailment, consistency checking | + +A field justified by the **frontend or indexer** is load-bearing now. A field justified *only* by the +not-yet-built reasoning engine is speculative and should be **deferred until that engine is real** — +this is the "don't out-reason your compute" lesson from the OWL species history, applied to our own roadmap. +Deferring costs nothing: every field is optional and additive, so we can add it the day a consumer appears. + +--- + +## 2. The exact problems we are solving + +Six concrete problems, each with a user story and the fields it needs. If a field doesn't serve one of +these, it's cut. + +### P1 — Bidirectional relationships without double-writing +> *Kames asserts "Alice **affiliated with** Acme." On Acme's page, Alice appears under affiliations +> automatically. Kames asserts "BigCo **parent organization** Acme"; Acme's page shows BigCo as parent +> and BigCo's page lists Acme as a subsidiary — from one edge.* + +The indexer synthesizes the reverse direction instead of forcing users to mint two atoms. +**Needs:** `isSymmetric` (reverse = same predicate), `inverse` (reverse = a different predicate). +**Consumer:** indexer + frontend. **Verdict: ship.** + +### P2 — Roll-up queries and signed reputation +> *"Show everyone affiliated with Acme" returns employees, alumni, and members — without the query +> enumerating every sub-relation. Bob's trust score sums endorsements and vouches as positive signal, +> distrust and opposition as negative.* + +**Needs:** `specializes` (employedBy/alumniOf/memberOf ⊑ affiliatedWith), `polarity` (signed aggregation). +**Consumer:** indexer (query expansion) + reputation. **Verdict: ship.** + +### P3 — Surfacing contradiction (the Intuition-native problem) +> *The triples `⟨DAO-X, trust, Bob⟩` and `⟨DAO-X, distrust, Bob⟩` — **same subject, same object**, +> disjoint predicates — both exist with stake. The system recognizes a priced contradiction, routes both +> sides into a single conflict market, and badges the pair as contested.* + +This is the problem classical fact-graphs never had and the one most worth being cutting-edge on. A +belief/trust graph is *valuable precisely because it can represent and price disagreement.* + +**Precision (audit A2):** `contradicts` is strictly **pair-level disjointness** (OWL +`propertyDisjointWith`): `⟨s, P, o⟩` and `⟨s, Q, o⟩` cannot both hold for the *same* subject/object pair. +It is **not** the mechanism for attester-level disagreement — "Alice asserts trust Bob, Carol asserts +distrust Bob" has *different subjects* and is not a logical contradiction; it's the graph working as +intended. That aggregate ("how contested is Bob?") is served by **`polarity`** (signed sum over edges +into Bob) plus the existing counter-triple mechanism. The two layers need different queries, and the +backend's conflict-detection join (integration doc §5.4) is built on the pair-level definition. + +**Known gap (audit D3):** converse-pair incoherence — `⟨A, betterThan, B⟩` alongside `⟨B, betterThan, A⟩` +— is *not* expressible by `contradicts` (same pair, different predicate ≠ reversed pair, same predicate). +That is asymmetry violation, and it has **no shipped mechanism** while `isAsymmetric` is deferred. We +accept the gap knowingly; comparative-predicate coherence is what graduates `isAsymmetric`. + +**Needs:** `contradicts` (trust ⊥ distrust). +**Consumer:** reputation/markets (emerging) + frontend (conflict badges). **Verdict: ship `contradicts`.** + +### P4 — Automatic frontend rendering from the predicate alone (the biggest near-term win) +> *The UI receives an edge and, without any per-predicate hardcoding, knows: the object of `imgUrl` is an +> image (render inline), the object of `url` is a link, the object of `attestedBy` is **another claim** +> (render a nested claim card), the object of `memberOf` is an **entity** (render a clickable node). +> Positive predicates render green, negative red. A `state` edge like `employedBy` shows an "as of" badge; +> a `permanent` edge like `authoredBy` does not.* + +This is the field set with the most immediate, concrete payoff, and it's the one the user explicitly +called out. The predicate spec becomes a **rendering contract**, eliminating a giant `switch` in the app. +**Needs:** `objectKind` (entity | claim | literal), `literalType` (audit A3 — image vs link vs date; +without it the literal branch still switches on predicate key), `polarity` (color/sentiment), +`temporalNature` (freshness badge), plus existing display fields. +**Consumer:** frontend, today. **Verdict: ship — highest confidence in the whole set.** + +### P5 — Data freshness and lifecycle +> *The graph knows `employedBy` can silently become false and flags it for re-attestation or display as +> historical, while `authoredBy` is write-once and never decays.* + +**Needs:** `temporalNature`. **Consumer:** frontend + data-quality tooling. **Verdict: ship.** + +### P6 — Machine-readability / interoperability +> *A third party (or a future reasoner) pulls our predicate's IPFS document and reads standard +> schema.org/OWL-shaped property characteristics without bespoke parsing.* + +This is served *for free* by the fields above as long as we serialize them in the existing +`additionalProperty` array using OWL-aligned names. **Verdict: no new fields; constrains naming/structure (see §4).** + +--- + +## 3. Field-by-field verdicts (the cuts are the point) + +| Field | Scenario it serves | Verdict | +|---|---|---| +| `isSymmetric` | P1 reciprocal edges | **Ship** | +| `inverse` (typed key) | P1 reverse-as-different-predicate | **Ship** | +| `isTransitive` | P2 closure ("places within California", org trees) | **Ship** | +| `specializes` | P2 roll-up query + reputation | **Ship** (needs curation) | +| `polarity` | P2 reputation, P4 color | **Ship** | +| `contradicts` | P3 belief markets | **Ship** (needs curation) | +| `isAsymmetric` | P3 validation; tells indexer "don't mirror" | **Defer** — see note below; demoted in per-field review | +| `objectKind` | P4 render literal/claim/entity differently | **Ship** (highest-value) | +| `literalType` *(audit A3)* | P4 — a literal alone doesn't say image vs link vs date; typed literals complete the rendering contract | **Ship** (~85) | +| `temporalNature` | P4 freshness badge, P5 lifecycle | **Ship** | +| `supersededBy` *(audit D1)* | deprecation forwarding — pickers redirect, old edges render "superseded" (Wikidata "replaced by") | **Soft-ship** (~70) | +| `marketPattern` | existing economic layer | **Keep** | +| `claimType` (factual/evaluative) | market-design + "opinion vs fact" badge | **Ship, simplified** — drop the `normative` value until a consumer needs it | +| `isHierarchical` | — derivable from `transitive + asymmetric + inverse` | **Cut** (redundant) | +| `isReflexive`/`isIrreflexive` | self-loop validation; rare, derivable (`asymmetric ⟹ irreflexive`) | **Defer** — no consumer creates self-loops today | +| `isFunctional` | "one manufacturer" conflict check | **Defer** — no validation engine consuming it yet | +| `isInverseFunctional` | identity resolution / dedup of minted entities | **Defer, but flagged strategic** — revisit when a dedup engine exists; powerful for permissionless minting | +| `equivalentTo` | predicate aliasing | **Cut** — we deliberately keep near-synonyms distinct; "smushing" risk (no-UNA) outweighs benefit | +| `verifiability` | onChain/offChain | **Cut** — ~correlated with `claimType`; a redundant second axis | +| property chains | grandparentOf from parentOf∘parentOf | **Cut for now** — most expensive axiom, no consumer | + +**Net change vs the original proposal:** we drop from ~7 logic booleans to **2** (`isSymmetric`, +`isTransitive`), cut `equivalentTo`/`verifiability`/`isHierarchical`, defer the functional family, +reflexivity, **and `isAsymmetric`**, and simplify `claimType`. That is roughly a 45% reduction in surface +area — and every survivor names a consumer that exists or is emerging. + +**Audit pass (2026-07-01, `predicate-spec-audit.md`) added:** `literalType` (ship, ~85 — completes the +rendering contract for literals), `supersededBy` (soft-ship, ~70 — deprecation forwarding), widened +`specializes` to an array (DAG, not tree), and left every cut/deferral standing. Zero removals. + +> **`isAsymmetric` was demoted to Defer** during the per-field review (`predicate-fields/is-asymmetric.md`, +> score 54). Its only unique value is write-time validation, which has no consumer yet; its "don't mirror +> the edge" signal is already implied by the *absence* of `isSymmetric` plus the presence of `inverse`. It +> graduates alongside `isFunctional` when a validation engine ships. The active algebraic set is therefore +> just `isSymmetric` + `isTransitive`, both with an indexer consumer today. + +--- + +## 4. Flat vs nested — decision: **flat** + +I previously recommended nesting (`logic{}`, `ontology{}`). **The research reverses that.** Three reasons: + +1. **The serialization target is flat.** Our IPFS document emits `additionalProperty: PropertyValue[]` — + a flat list of `{name, value}`. Nesting in TS just means flattening again at serialization. The data's + destination is flat; the source should match it. +2. **The entire tradition is flat.** OWL doesn't nest — it asserts `P rdf:type owl:SymmetricProperty`, + `P owl:inverseOf Q`. schema.org is flat key/value. RDF is flat triples. We get interoperability by + *looking like* the standards, and the standards are flat. +3. **After pruning, there's nothing to nest.** ~10 semantic fields, most absent on any given spec. Wrapping + 3 booleans in a `logic:{}` object is ceremony that adds a layer of access (`spec.logic?.symmetric`) and + complicates `as const satisfies`, grep-ability, and diffs for zero benefit. + +Nesting earns its keep when a group is large, frequently co-populated, and namespaced against collisions. +None of those hold here. **Go flat.** Use the OWL-aligned `is*` convention for the algebraic booleans so +the names self-document and map 1:1 to the serialized property names. + +(Corollary: don't rename `conjugates`. We dropped "conjugative" as a *relational* term entirely, so the +collision is gone — `conjugates` can keep meaning grammatical inflection. One less migration.) + +--- + +## 5. The resulting spec + +```ts +export interface PredicateSpec { + // identity & lifecycle + key: string; + name: string; + description: string; + category: PredicateCategory; + status: PredicateStatus; + supersededBy?: PredicateKey; // audit D1 — required-when status==='deprecated' has a successor + + // economic (Intuition-native) + marketPattern: MarketPattern; + + // rendering contract (frontend reads these directly) + objectKind?: 'entity' | 'claim' | 'literal'; + literalType?: 'url' | 'image' | 'date' | 'number' | 'text'; // audit A3 — only when objectKind==='literal' + polarity?: 'positive' | 'negative' | 'neutral'; + temporalNature?: 'permanent' | 'state' | 'event'; + claimType?: 'factual' | 'evaluative'; + + // algebraic (OWL-aligned, flat, is* convention) + isSymmetric?: boolean; + isTransitive?: boolean; + // isAsymmetric?: boolean; // deferred — add with the validation engine (see predicate-fields/is-asymmetric.md) + + // inter-predicate (typed key references — validated in definePredicateRecord) + inverse?: PredicateKey; + specializes?: readonly PredicateKey[]; // audit B1 — array: property hierarchies are DAGs, not trees + contradicts?: readonly PredicateKey[]; + + // display + conjugates: boolean; // grammatical inflection (unchanged) + thirdPerson?: string; + examples?: readonly string[]; +} +``` + +### 5.1 The complete validation/derivation rule set (audit B2) + +`definePredicateRecord` stays the single consistency gate. The DL literature gives the **complete** rule +set for our fields — enforce all of it, not just the obvious three: + +| # | Rule | Kind | +|---|---|---| +| 1 | `isSymmetric` ⟹ no distinct `inverse` (inverse = self) | error | +| 2 | Inverse mirroring: `P.inverse = Q ⟺ Q.inverse = P` | error | +| 3 | `contradicts` symmetric: `B ∈ A.contradicts ⟺ A ∈ B.contradicts` | error | +| 4 | `contradicts` irreflexive: `P ∉ P.contradicts` | error | +| 5 | `specializes` acyclic (no `P ⊑ … ⊑ P`) | error | +| 6 | P may not contradict its own `specializes` ancestor/descendant (`P ⊑ Q` ∧ `P ⊥ Q` ⟹ P unsatisfiable) | error | +| 7 | **Derive contradiction closure** down the hierarchy: `P ⊑ Q` ∧ `Q ⊥ R` ⟹ `P ⊥ R`, expanded at build time (else `vouchFor ⊑ trust`, `trust ⊥ distrust` leaves a `vouchFor`/`distrust` conflict undetected) | derivation | +| 8 | Inverse pairs mirror algebra: `isTransitive` equal (theorem), `temporalNature`/`claimType` equal; `polarity` equal | error / polarity=lint | +| 9 | `isSymmetric && isTransitive` ⟹ equivalence-relation behavior — allowed only for the explicit identity allow-list (`sameAs`); warn otherwise | lint | +| 10 | `polarity` present (sentiment predicate) ∧ `isTransitive` ⟹ error — sentiment is never transitive (Guha 2004; see `is-transitive.md`) | error | +| 11 | `isTransitive` is **not** inherited via `specializes` — documented invariant so the indexer never expands closure over sub-properties | doc guard | +| 12 | `literalType` present ⟹ `objectKind === 'literal'` | error | +| 13 | `objectKind === 'claim'` ∧ `inverse` present ⟹ warn (the inverse's subject would be a claim — near-always a modeling smell) | lint | +| 14 | `supersededBy` points at a non-deprecated spec; only valid when `status === 'deprecated'` | error | + +Rules 6, 7, and 11 are the silent-bug ones — contradiction × hierarchy interaction is invisible until a +conflict query misses, and "transitive-ish inheritance" is the most natural indexer bug to write. + +### 5.2 Mint-time canonicalization for symmetric & inverse predicates (audit A1 — blocking) + +OWL assumes reverse-edge synthesis is a free inference. **Intuition triples have deterministic IDs and +their own vaults** — so `⟨Acme, partnerOf, BigCo⟩` and `⟨BigCo, partnerOf, Acme⟩` are the *same fact* +hashing to **two triple IDs → two markets**, splitting stake. Same for inverse pairs +(`⟨Alice, employedBy, Acme⟩` vs `⟨Acme, employs, Alice⟩`). Wikidata's operational lesson is exactly this: +inverse pairs demanded permanent bot-sync, and the community's answer was canonical directions. We fix it +at mint, not with bots: + +1. **Symmetric predicates:** builders (`primitives`) canonically order subject/object (by atom ID) before + computing the triple ID — both user intents resolve to one triple, one market. +2. **Inverse pairs:** `definePredicateRecord` derives a **canonical direction** per pair (deterministic + rule, e.g. lexicographically smaller key is canonical); builders normalize an assertion in the + non-canonical direction to the canonical triple. The non-canonical predicate remains fully usable in + UI/queries — it's a *view*, not a second fact. +3. **Indexer backstop:** duplicates minted before the rule (or via raw protocol calls) are detected and + linked (the `sibling_triple_id` pattern, as for counter-triples), with an explicit aggregate-stake + presentation policy. + +Without this, edge synthesis *encourages* liquidity fragmentation — it's the one place the proposal was +economically unsound, and it's why canonicalization ships with the fields, not after them. + +### 5.3 Normative serialization mapping (audit B3 — makes P6 real) + +Interoperability is only "for free" if the field↔IRI mapping is pinned and implemented exactly: + +| Spec field | Serialized as | Standard | +|---|---|---| +| `isSymmetric: true` | `rdf:type owl:SymmetricProperty` | OWL 2 | +| `isTransitive: true` | `rdf:type owl:TransitiveProperty` | OWL 2 | +| `inverse` | `owl:inverseOf` | OWL 2 | +| `specializes` | `rdfs:subPropertyOf` (one entry per parent) | RDFS | +| `contradicts` | `owl:propertyDisjointWith` | OWL 2 | +| `supersededBy` | `schema:supersededBy` | schema.org | +| `literalType` | XSD datatype on the range (`xsd:anyURI`, `xsd:date`, `xsd:decimal`, …) | RDF/XSD | +| `objectKind` / `polarity` / `temporalNature` / `claimType` / `marketPattern` | Intuition-namespaced `PropertyValue` entries — our pragmatic extensions, no standard equivalent (that's fine) | — | + +--- + +## 6. Pros, cons, and the value proposition + +**Pros** +- **Self-describing graph → automatic rendering.** The frontend stops hardcoding per-predicate behavior; + the spec *is* the rendering contract (P4). This is the clearest, nearest win. +- **Cheap, scalable inference.** The shipped set is exactly the forward-chainable OWL 2 RL core — symmetry, + transitivity, inverse, sub-property, disjointness. Computable at graph scale; no decidability cliffs. +- **Fewer stored edges.** Symmetric/inverse synthesis means we store one direction and serve both. +- **A differentiator, not a clone.** `contradicts` + `polarity` + `marketPattern` make this a *belief and + trust* graph that prices disagreement — something no classical fact-graph (DBpedia, Wikidata, Google KG) + was built to do. That is the cutting-edge claim, and it's grounded in signed-network theory, not vibes. +- **Interoperability for free** via OWL/schema.org-aligned flat naming. + +**Cons / risks (and mitigations)** +- *Authoring burden across 133 specs.* → Most fields are optional; `objectKind`/`polarity`/`temporalNature`/ + the booleans are mechanical and scriptable; only `specializes`/`contradicts`/`inverse` need human curation. +- *Cross-referential consistency* (inverse/specializes/contradicts form a predicate graph that can drift). + → `definePredicateRecord` validates it at build time; an inconsistent set fails the build. +- *Encoding reasoning we can't run.* → Explicitly mitigated by the cuts in §3: we ship the RL core and defer + chains, equality, and the functional family until a consumer exists. +- *Modeling opinion as fact.* → `claimType` keeps the distinction explicit so markets/UI treat them differently. + +**The one-sentence value proposition:** *Every predicate carries just enough machine-readable structure +that the graph can render itself, query across relationship hierarchies, synthesize reverse edges, and +price disagreement — using the proven flat vocabulary of 30 years of knowledge-graph research, and nothing +more than that.* + +--- + +## 7. What we are explicitly NOT doing (and why that's a feature) + +- **Not** building domain/range typing into specs — it lives in `@0xintuition/classifications` (validation, + not OWL inference). One source of truth. +- **Not** shipping property chains, equality reasoning, or the functional family — no consumer yet; they're + the expensive end of the frontier and we can add them the day a reasoner exists. +- **Not** nesting — the data's destination and the entire standards tradition are flat. +- **Not** asserting global, timeless truth so hard we can't add Cyc-style context-scoping later. +- **Not (yet) putting the metadata in the graph itself — but never designing that out.** Wikidata models + property metadata as ordinary statements *on the property entities*, which is why its community can + extend and contest property semantics without a software release. Our metadata lives in the TS package + + IPFS docs for now (deterministic, reviewable, ships with code) — but the end-state for a permissionless + graph is **self-description**: `⟨trust, contradicts, distrust⟩` as an actual, stakeable triple. The + design is compatible (predicates are atoms; the meta-relations are just more predicates); "the community + disagrees with our curation" eventually gets an on-protocol answer, not a GitHub issue. (Audit D2.) + +Restraint here is the design. The graph that ships the *minimal sufficient* property set — and computes all +of it — beats the graph that declares axioms it can never run. + +--- + +## 8. Open questions for sign-off + +1. `claimType` — ship 2-value (`factual`/`evaluative`) now, or hold it entirely until a market actually + branches on it? (Leaning: ship; it's cheap and drives a frontend badge today.) +2. `polarity` — do we need `neutral` as a value, or is "absent = no sentiment" enough? (Leaning: keep + `neutral` only for predicates like `neutralOn` whose sentiment is *explicitly* zero, distinct from absent.) +3. `inverseFunctional` for identity resolution — strategically important for permissionless dedup. Do we + want to pull it forward into P1 to start collecting the data before the dedup engine exists? diff --git a/bun.lock b/bun.lock index bf3fe2e..982c917 100644 --- a/bun.lock +++ b/bun.lock @@ -33,27 +33,27 @@ "name": "@0xintuition/classifications", "version": "0.1.0-alpha.0", "devDependencies": { - "@0xintuition/predicates": "workspace:*", - "@0xintuition/schema-org": "workspace:*", - "typescript": "catalog:", - "vitest": "catalog:", + "@0xintuition/predicates": "0.1.0-alpha.0", + "@0xintuition/schema-org": "0.1.0-alpha.0", + "typescript": "^5.9.3", + "vitest": "^4.0.16", }, }, "packages/curves": { "name": "@0xintuition/curves", "version": "0.1.0-alpha.0", "devDependencies": { - "typescript": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "vitest": "^4.0.16", }, }, "packages/deployments": { "name": "@0xintuition/deployments", "version": "0.1.0-alpha.0", "devDependencies": { - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", }, "peerDependencies": { "viem": "^2.0.0", @@ -63,9 +63,9 @@ "name": "@0xintuition/ids", "version": "0.1.0-alpha.0", "devDependencies": { - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", }, "peerDependencies": { "viem": "^2.0.0", @@ -75,12 +75,12 @@ "name": "@0xintuition/periphery", "version": "0.1.0-alpha.0", "dependencies": { - "@0xintuition/deployments": "workspace:*", + "@0xintuition/deployments": "0.1.0-alpha.0", }, "devDependencies": { - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", }, "peerDependencies": { "viem": "^2.0.0", @@ -90,12 +90,12 @@ "name": "@0xintuition/predicates", "version": "0.1.0-alpha.0", "dependencies": { - "@0xintuition/ids": "workspace:*", + "@0xintuition/ids": "0.1.0-alpha.0", }, "devDependencies": { - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", }, "peerDependencies": { "viem": "^2.0.0", @@ -105,14 +105,14 @@ "name": "@0xintuition/primitives", "version": "0.1.0-alpha.0", "dependencies": { - "@0xintuition/classifications": "workspace:*", - "@0xintuition/ids": "workspace:*", - "@0xintuition/predicates": "workspace:*", + "@0xintuition/classifications": "0.1.0-alpha.0", + "@0xintuition/ids": "0.1.0-alpha.0", + "@0xintuition/predicates": "0.1.0-alpha.0", }, "devDependencies": { - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", }, "peerDependencies": { "viem": "^2.0.0", @@ -122,12 +122,12 @@ "name": "@0xintuition/protocol", "version": "3.0.0", "dependencies": { - "@0xintuition/curves": "workspace:*", + "@0xintuition/curves": "0.1.0-alpha.0", }, "devDependencies": { - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", }, "peerDependencies": { "viem": "^2.0.0", @@ -137,21 +137,21 @@ "name": "@0xintuition/react", "version": "0.1.0-alpha.0", "dependencies": { - "@0xintuition/deployments": "workspace:*", - "@0xintuition/ids": "workspace:*", - "@0xintuition/protocol": "workspace:*", + "@0xintuition/deployments": "0.1.0-alpha.0", + "@0xintuition/ids": "0.1.0-alpha.0", + "@0xintuition/protocol": "3.0.0", }, "devDependencies": { - "@tanstack/react-query": "catalog:", - "@testing-library/react": "catalog:", - "@types/react": "catalog:", - "jsdom": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "typescript": "catalog:", - "viem": "catalog:", - "vitest": "catalog:", - "wagmi": "catalog:", + "@tanstack/react-query": "^5.90.2", + "@testing-library/react": "^16.3.0", + "@types/react": "19.1.10", + "jsdom": "^26.1.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "typescript": "^5.9.3", + "viem": "^2.31.4", + "vitest": "^4.0.16", + "wagmi": "^2.15.4", }, "peerDependencies": { "@tanstack/react-query": ">=5", @@ -164,8 +164,8 @@ "name": "@0xintuition/schema-org", "version": "0.1.0-alpha.0", "devDependencies": { - "typescript": "catalog:", - "vitest": "catalog:", + "typescript": "^5.9.3", + "vitest": "^4.0.16", }, }, }, diff --git a/packages/classifications/src/classification-references.test.ts b/packages/classifications/src/classification-references.test.ts index 00a35e2..269ce94 100644 --- a/packages/classifications/src/classification-references.test.ts +++ b/packages/classifications/src/classification-references.test.ts @@ -50,8 +50,10 @@ describe('classification references', () => { ); for (const field of spec.fields) { + // Fields without a schemaProperty are Intuition extension fields + // (e.g. Podcasting 2.0 GUIDs, purl coordinates, content hashes) + // and carry no schema.org provenance. if (!field.schemaProperty) { - issues.push(`${spec.slug}.${field.key}: missing schemaProperty pointer`); continue; } diff --git a/packages/classifications/src/creation-profile.test.ts b/packages/classifications/src/creation-profile.test.ts index f8f0b84..28a396b 100644 --- a/packages/classifications/src/creation-profile.test.ts +++ b/packages/classifications/src/creation-profile.test.ts @@ -37,6 +37,7 @@ describe('creation profiles', () => { 'name', 'byArtist', 'inAlbum', + 'isrc', 'sameAs', ]); expect(musicRecordingCreationProfile.fields[0]?.schema).toMatchObject({ diff --git a/packages/classifications/src/generated/creation/defined-term.ts b/packages/classifications/src/generated/creation/defined-term.ts index 9edd637..f416290 100644 --- a/packages/classifications/src/generated/creation/defined-term.ts +++ b/packages/classifications/src/generated/creation/defined-term.ts @@ -51,6 +51,44 @@ export const definedTermCreationProfile = { rangeIncludes: ['Text', 'TextObject'], }, }, + { + key: 'inDefinedTermSet', + label: 'Term Set', + description: 'The glossary or vocabulary the term belongs to.', + fieldType: 'string', + required: false, + placeholder: 'https://schema.org', + schemaProperty: 'inDefinedTermSet', + schema: { + context: 'https://schema.org/', + property: 'inDefinedTermSet', + propertyId: 'schema:inDefinedTermSet', + label: 'inDefinedTermSet', + comment: 'A [[DefinedTermSet]] that contains this term.', + originType: 'DefinedTerm', + originTypeId: 'schema:DefinedTerm', + rangeIncludes: ['DefinedTermSet', 'URL'], + }, + }, + { + key: 'termCode', + label: 'Term Code', + description: 'The code identifying the term within its set.', + fieldType: 'string', + required: false, + placeholder: 'merkle-tree', + schemaProperty: 'termCode', + schema: { + context: 'https://schema.org/', + property: 'termCode', + propertyId: 'schema:termCode', + label: 'termCode', + comment: 'A code that identifies this [[DefinedTerm]] within a [[DefinedTermSet]].', + originType: 'DefinedTerm', + originTypeId: 'schema:DefinedTerm', + rangeIncludes: ['Text'], + }, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/image.ts b/packages/classifications/src/generated/creation/image.ts index 8e60626..2b9a23d 100644 --- a/packages/classifications/src/generated/creation/image.ts +++ b/packages/classifications/src/generated/creation/image.ts @@ -91,6 +91,15 @@ export const imageCreationProfile = { rangeIncludes: ['DefinedTerm', 'Text', 'URL'], }, }, + { + key: 'contentHash', + label: 'Content Hash', + description: 'Hash of the image bytes as alg:hex, e.g. sha256:.', + fieldType: 'string', + required: false, + placeholder: 'sha256:9f86d081884c7d65…', + schema: null, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/local-business.ts b/packages/classifications/src/generated/creation/local-business.ts index ef1fe35..44f84ab 100644 --- a/packages/classifications/src/generated/creation/local-business.ts +++ b/packages/classifications/src/generated/creation/local-business.ts @@ -89,6 +89,46 @@ export const localBusinessCreationProfile = { rangeIncludes: ['URL'], }, }, + { + key: 'latitude', + label: 'Latitude', + description: 'The latitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '37.7823', + schemaProperty: 'latitude', + schema: { + context: 'https://schema.org/', + property: 'latitude', + propertyId: 'schema:latitude', + label: 'latitude', + comment: + 'The latitude of a location. For example ```37.42242``` ([WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)).', + originType: 'Place', + originTypeId: 'schema:Place', + rangeIncludes: ['Number', 'Text'], + }, + }, + { + key: 'longitude', + label: 'Longitude', + description: 'The longitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '-122.4076', + schemaProperty: 'longitude', + schema: { + context: 'https://schema.org/', + property: 'longitude', + propertyId: 'schema:longitude', + label: 'longitude', + comment: + 'The longitude of a location. For example ```-122.08585``` ([WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)).', + originType: 'Place', + originTypeId: 'schema:Place', + rangeIncludes: ['Number', 'Text'], + }, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/location.ts b/packages/classifications/src/generated/creation/location.ts index edaaac6..ea7183d 100644 --- a/packages/classifications/src/generated/creation/location.ts +++ b/packages/classifications/src/generated/creation/location.ts @@ -51,6 +51,46 @@ export const locationCreationProfile = { rangeIncludes: ['PostalAddress', 'Text'], }, }, + { + key: 'latitude', + label: 'Latitude', + description: 'The latitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '48.8584', + schemaProperty: 'latitude', + schema: { + context: 'https://schema.org/', + property: 'latitude', + propertyId: 'schema:latitude', + label: 'latitude', + comment: + 'The latitude of a location. For example ```37.42242``` ([WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)).', + originType: 'Place', + originTypeId: 'schema:Place', + rangeIncludes: ['Number', 'Text'], + }, + }, + { + key: 'longitude', + label: 'Longitude', + description: 'The longitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '2.2945', + schemaProperty: 'longitude', + schema: { + context: 'https://schema.org/', + property: 'longitude', + propertyId: 'schema:longitude', + label: 'longitude', + comment: + 'The longitude of a location. For example ```-122.08585``` ([WGS 84](https://en.wikipedia.org/wiki/World_Geodetic_System)).', + originType: 'Place', + originTypeId: 'schema:Place', + rangeIncludes: ['Number', 'Text'], + }, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/mobile-application.ts b/packages/classifications/src/generated/creation/mobile-application.ts index 78fd249..282a1aa 100644 --- a/packages/classifications/src/generated/creation/mobile-application.ts +++ b/packages/classifications/src/generated/creation/mobile-application.ts @@ -89,6 +89,15 @@ export const mobileApplicationCreationProfile = { rangeIncludes: ['URL'], }, }, + { + key: 'bundleId', + label: 'Bundle / Package ID', + description: 'The store bundle identifier or Android package name.', + fieldType: 'string', + required: false, + placeholder: 'com.spotify.music', + schema: null, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/music-recording.ts b/packages/classifications/src/generated/creation/music-recording.ts index d3a8203..e9f6778 100644 --- a/packages/classifications/src/generated/creation/music-recording.ts +++ b/packages/classifications/src/generated/creation/music-recording.ts @@ -70,6 +70,25 @@ export const musicRecordingCreationProfile = { rangeIncludes: ['MusicAlbum'], }, }, + { + key: 'isrc', + label: 'ISRC', + description: 'The International Standard Recording Code when known.', + fieldType: 'string', + required: false, + placeholder: 'USSM10007459', + schemaProperty: 'isrcCode', + schema: { + context: 'https://schema.org/', + property: 'isrcCode', + propertyId: 'schema:isrcCode', + label: 'isrcCode', + comment: 'The International Standard Recording Code for the recording.', + originType: 'MusicRecording', + originTypeId: 'schema:MusicRecording', + rangeIncludes: ['Text'], + }, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/podcast-episode.ts b/packages/classifications/src/generated/creation/podcast-episode.ts index 8d844de..b16c9f6 100644 --- a/packages/classifications/src/generated/creation/podcast-episode.ts +++ b/packages/classifications/src/generated/creation/podcast-episode.ts @@ -90,6 +90,24 @@ export const podcastEpisodeCreationProfile = { rangeIncludes: ['Date', 'DateTime'], }, }, + { + key: 'feedGuid', + label: 'Feed GUID', + description: 'The Podcasting 2.0 GUID of the feed this item belongs to.', + fieldType: 'string', + required: false, + placeholder: '917393e3-1b1e-5cef-ace4-edaa54e1f810', + schema: null, + }, + { + key: 'itemGuid', + label: 'Item GUID', + description: 'The RSS value of this episode item within its feed.', + fieldType: 'string', + required: false, + placeholder: 'urn:example:ep42', + schema: null, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/podcast-series.ts b/packages/classifications/src/generated/creation/podcast-series.ts index 4f45d94..eea50fa 100644 --- a/packages/classifications/src/generated/creation/podcast-series.ts +++ b/packages/classifications/src/generated/creation/podcast-series.ts @@ -51,6 +51,35 @@ export const podcastSeriesCreationProfile = { rangeIncludes: ['URL'], }, }, + { + key: 'feedUrl', + label: 'RSS Feed URL', + description: 'The RSS feed URL; used to derive the Podcasting 2.0 GUID.', + fieldType: 'url', + required: false, + placeholder: 'https://feeds.example.com/show.xml', + schemaProperty: 'webFeed', + schema: { + context: 'https://schema.org/', + property: 'webFeed', + propertyId: 'schema:webFeed', + label: 'webFeed', + comment: + 'The URL for a feed, e.g. associated with a podcast series, blog, or series of date-stamped updates. This is usually RSS or Atom.', + originType: 'PodcastSeries', + originTypeId: 'schema:PodcastSeries', + rangeIncludes: ['DataFeed', 'URL'], + }, + }, + { + key: 'podcastGuid', + label: 'Podcast GUID', + description: 'The declared Podcasting 2.0 value; survives feed migrations.', + fieldType: 'string', + required: false, + placeholder: '917393e3-1b1e-5cef-ace4-edaa54e1f810', + schema: null, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/social-media-account.ts b/packages/classifications/src/generated/creation/social-media-account.ts index 8a667a7..6a900e7 100644 --- a/packages/classifications/src/generated/creation/social-media-account.ts +++ b/packages/classifications/src/generated/creation/social-media-account.ts @@ -40,6 +40,15 @@ export const socialMediaAccountCreationProfile = { placeholder: 'https://x.com/karpathy', schema: null, }, + { + key: 'platformUserId', + label: 'Platform User ID', + description: 'The immutable platform-assigned user ID (survives handle renames).', + fieldType: 'string', + required: false, + placeholder: '295218901', + schema: null, + }, ], relationships: [ { @@ -107,7 +116,7 @@ export const socialMediaAccountCreationProfile = { ], }, ], - availableFieldCount: 3, + availableFieldCount: 4, } as const satisfies CreationProfile; export const creationProfile = socialMediaAccountCreationProfile; diff --git a/packages/classifications/src/generated/creation/software.ts b/packages/classifications/src/generated/creation/software.ts index 5fd4d90..415dfa7 100644 --- a/packages/classifications/src/generated/creation/software.ts +++ b/packages/classifications/src/generated/creation/software.ts @@ -52,6 +52,15 @@ export const softwareCreationProfile = { rangeIncludes: ['URL'], }, }, + { + key: 'packageUrl', + label: 'Package URL', + description: 'The purl package coordinate, version-free.', + fieldType: 'string', + required: false, + placeholder: 'pkg:npm/react', + schema: null, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/creation/video-object.ts b/packages/classifications/src/generated/creation/video-object.ts index a659023..338100f 100644 --- a/packages/classifications/src/generated/creation/video-object.ts +++ b/packages/classifications/src/generated/creation/video-object.ts @@ -70,6 +70,15 @@ export const videoObjectCreationProfile = { rangeIncludes: ['URL'], }, }, + { + key: 'contentHash', + label: 'Content Hash', + description: 'Hash of the video bytes as alg:hex, e.g. sha256:.', + fieldType: 'string', + required: false, + placeholder: 'sha256:9f86d081884c7d65…', + schema: null, + }, { key: 'sameAs', label: 'Canonical References', diff --git a/packages/classifications/src/generated/specs/aggregate-rating.ts b/packages/classifications/src/generated/specs/aggregate-rating.ts index f92c324..baa561f 100644 --- a/packages/classifications/src/generated/specs/aggregate-rating.ts +++ b/packages/classifications/src/generated/specs/aggregate-rating.ts @@ -47,4 +47,7 @@ export const aggregateRating: ClassificationSpec = { }, ], defaults: { pluginId: 'aggregate-rating', provider: 'opengraph' }, + // D23: withdrawn from IID scope — remodeled as a triple + // (subject —hasAggregateRating→ provider atom). + identity: null, }; diff --git a/packages/classifications/src/generated/specs/article.ts b/packages/classifications/src/generated/specs/article.ts index 6ce757e..489b9a4 100644 --- a/packages/classifications/src/generated/specs/article.ts +++ b/packages/classifications/src/generated/specs/article.ts @@ -56,4 +56,12 @@ export const article: ClassificationSpec = { }, ], defaults: { pluginId: 'article', provider: 'dictionary' }, + identity: { + identifies: 'an article', + ladder: [ + { kind: 'scheme', scheme: 'doi', source: { kind: 'field', key: 'url' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'headline', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/book.ts b/packages/classifications/src/generated/specs/book.ts index cc8d8f2..44a1513 100644 --- a/packages/classifications/src/generated/specs/book.ts +++ b/packages/classifications/src/generated/specs/book.ts @@ -54,4 +54,15 @@ export const book: ClassificationSpec = { }, ], defaults: { pluginId: 'book' }, + identity: { + identifies: 'the work; isbn identifies editions, bridged via equivalence', + ladder: [ + { kind: 'scheme', scheme: 'isbn', source: { kind: 'field', key: 'isbn' } }, + // D27: T2 community open data + { kind: 'scheme', scheme: 'olid', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + // D21: author omitted — authorship is an authoredBy triple + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/brand.ts b/packages/classifications/src/generated/specs/brand.ts index 847e57d..92dc69b 100644 --- a/packages/classifications/src/generated/specs/brand.ts +++ b/packages/classifications/src/generated/specs/brand.ts @@ -38,4 +38,11 @@ export const brand: ClassificationSpec = { }, ], defaults: { pluginId: 'brand', provider: 'company-profile' }, + identity: { + identifies: 'a brand/concept (instance businesses are local-business atoms)', + ladder: [ + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'gen1', tag: 2, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/comment.ts b/packages/classifications/src/generated/specs/comment.ts index 2f22bf0..ddd687f 100644 --- a/packages/classifications/src/generated/specs/comment.ts +++ b/packages/classifications/src/generated/specs/comment.ts @@ -47,4 +47,8 @@ export const comment: ClassificationSpec = { }, ], defaults: { pluginId: 'comment', provider: 'social-media-posting' }, + identity: { + identifies: 'one comment on one subject (relational entity)', + ladder: [{ kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }], + }, }; diff --git a/packages/classifications/src/generated/specs/company.ts b/packages/classifications/src/generated/specs/company.ts index 4481397..000232a 100644 --- a/packages/classifications/src/generated/specs/company.ts +++ b/packages/classifications/src/generated/specs/company.ts @@ -49,4 +49,12 @@ export const company: ClassificationSpec = { }, ], defaults: { pluginId: 'company' }, + identity: { + identifies: 'a legal entity', + ladder: [ + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/dataset.ts b/packages/classifications/src/generated/specs/dataset.ts index a78f3da..6d2d5b9 100644 --- a/packages/classifications/src/generated/specs/dataset.ts +++ b/packages/classifications/src/generated/specs/dataset.ts @@ -38,4 +38,12 @@ export const dataset: ClassificationSpec = { }, ], defaults: { pluginId: 'dataset', provider: 'opengraph' }, + identity: { + identifies: 'a dataset (version lineage out of ID scope)', + ladder: [ + { kind: 'scheme', scheme: 'doi', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/defined-term.ts b/packages/classifications/src/generated/specs/defined-term.ts index 5e5e4e0..eb3b238 100644 --- a/packages/classifications/src/generated/specs/defined-term.ts +++ b/packages/classifications/src/generated/specs/defined-term.ts @@ -27,6 +27,24 @@ export const definedTerm: ClassificationSpec = { required: false, placeholder: 'Structured, semantic network that organizes data.', }, + { + key: 'inDefinedTermSet', + schemaProperty: 'inDefinedTermSet', + label: 'Term Set', + description: 'The glossary or vocabulary the term belongs to.', + fieldType: 'string', + required: false, + placeholder: 'https://schema.org', + }, + { + key: 'termCode', + schemaProperty: 'termCode', + label: 'Term Code', + description: 'The code identifying the term within its set.', + fieldType: 'string', + required: false, + placeholder: 'merkle-tree', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -38,4 +56,11 @@ export const definedTerm: ClassificationSpec = { }, ], defaults: { pluginId: 'defined-term', provider: 'dictionary' }, + identity: { + identifies: 'a term within a term set', + ladder: [ + { kind: 'scheme', scheme: 'termset', source: { kind: 'derivation', name: 'termset-term' } }, + { kind: 'gen1', tag: 2, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/ethereum-account.ts b/packages/classifications/src/generated/specs/ethereum-account.ts index 96fa4b0..5c95831 100644 --- a/packages/classifications/src/generated/specs/ethereum-account.ts +++ b/packages/classifications/src/generated/specs/ethereum-account.ts @@ -22,4 +22,10 @@ export const ethereumAccount: ClassificationSpec = { }, ], defaults: { pluginId: 'ethereum-account', provider: 'etherscan' }, + identity: { + identifies: 'an EOA/keypair (D24: fixed eip155:1)', + ladder: [ + { kind: 'scheme', scheme: 'caip10', source: { kind: 'derivation', name: 'caip10-eoa' } }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/ethereum-erc20.ts b/packages/classifications/src/generated/specs/ethereum-erc20.ts index eed5045..d76b8ad 100644 --- a/packages/classifications/src/generated/specs/ethereum-erc20.ts +++ b/packages/classifications/src/generated/specs/ethereum-erc20.ts @@ -54,4 +54,10 @@ export const ethereumErc20: ClassificationSpec = { }, ], defaults: { pluginId: 'ethereum-smart-contract', provider: 'etherscan' }, + identity: { + identifies: 'a token asset on a chain', + ladder: [ + { kind: 'scheme', scheme: 'caip19', source: { kind: 'derivation', name: 'caip19-erc20' } }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/ethereum-smart-contract.ts b/packages/classifications/src/generated/specs/ethereum-smart-contract.ts index d869c0e..72618a3 100644 --- a/packages/classifications/src/generated/specs/ethereum-smart-contract.ts +++ b/packages/classifications/src/generated/specs/ethereum-smart-contract.ts @@ -30,4 +30,10 @@ export const ethereumSmartContract: ClassificationSpec = { }, ], defaults: { pluginId: 'ethereum-smart-contract', provider: 'etherscan' }, + identity: { + identifies: 'a deployed contract', + ladder: [ + { kind: 'scheme', scheme: 'caip10', source: { kind: 'derivation', name: 'caip10-contract' } }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/event.ts b/packages/classifications/src/generated/specs/event.ts index 0977b4f..328788f 100644 --- a/packages/classifications/src/generated/specs/event.ts +++ b/packages/classifications/src/generated/specs/event.ts @@ -55,4 +55,20 @@ export const event: ClassificationSpec = { }, ], defaults: { pluginId: 'event' }, + identity: { + identifies: 'an event occurrence', + ladder: [ + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }, + { + kind: 'gen1', + tag: 3, + recipe: [ + { key: 'name', from: 'field' }, + { key: 'startDate', from: 'field' }, + ], + }, + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/image.ts b/packages/classifications/src/generated/specs/image.ts index 03afe41..c53dcd1 100644 --- a/packages/classifications/src/generated/specs/image.ts +++ b/packages/classifications/src/generated/specs/image.ts @@ -53,6 +53,14 @@ export const image: ClassificationSpec = { required: false, placeholder: 'knowledge-graph', }, + { + key: 'contentHash', + label: 'Content Hash', + description: 'Hash of the image bytes as alg:hex, e.g. sha256:.', + fieldType: 'string', + required: false, + placeholder: 'sha256:9f86d081884c7d65…', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -64,4 +72,12 @@ export const image: ClassificationSpec = { }, ], defaults: { pluginId: 'image', provider: 'github' }, + identity: { + identifies: 'an image — byte-identity first; url is a mutable manifestation fallback (F4)', + ladder: [ + { kind: 'scheme', scheme: 'hash', source: { kind: 'field', key: 'contentHash' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/job-posting.ts b/packages/classifications/src/generated/specs/job-posting.ts index fc345f9..ffb1720 100644 --- a/packages/classifications/src/generated/specs/job-posting.ts +++ b/packages/classifications/src/generated/specs/job-posting.ts @@ -65,4 +65,20 @@ export const jobPosting: ClassificationSpec = { }, ], defaults: { pluginId: 'job-posting', provider: 'opengraph' }, + identity: { + identifies: 'one posting', + ladder: [ + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + // D21: hiringOrganization omitted — employer linkage is a triple + { + kind: 'gen1', + tag: 2, + recipe: [ + { key: 'title', from: 'field' }, + { key: 'datePosted', from: 'field' }, + ], + }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'title', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/local-business.ts b/packages/classifications/src/generated/specs/local-business.ts index 7db0328..b264574 100644 --- a/packages/classifications/src/generated/specs/local-business.ts +++ b/packages/classifications/src/generated/specs/local-business.ts @@ -52,6 +52,24 @@ export const localBusiness: ClassificationSpec = { required: false, placeholder: 'https://bluebottlecoffee.com', }, + { + key: 'latitude', + schemaProperty: 'latitude', + label: 'Latitude', + description: 'The latitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '37.7823', + }, + { + key: 'longitude', + schemaProperty: 'longitude', + label: 'Longitude', + description: 'The longitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '-122.4076', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -63,4 +81,19 @@ export const localBusiness: ClassificationSpec = { }, ], defaults: { pluginId: 'local-business', provider: 'places' }, + identity: { + identifies: 'one business at one place (instance level)', + ladder: [ + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { + kind: 'gen1', + tag: 2, + recipe: [ + { key: 'name', from: 'field' }, + { key: 'geo7', from: 'geohash', precision: 7 }, + ], + }, + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/location.ts b/packages/classifications/src/generated/specs/location.ts index caf71da..87222d8 100644 --- a/packages/classifications/src/generated/specs/location.ts +++ b/packages/classifications/src/generated/specs/location.ts @@ -34,6 +34,24 @@ export const location: ClassificationSpec = { required: false, placeholder: 'San Francisco, CA 94129', }, + { + key: 'latitude', + schemaProperty: 'latitude', + label: 'Latitude', + description: 'The latitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '48.8584', + }, + { + key: 'longitude', + schemaProperty: 'longitude', + label: 'Longitude', + description: 'The longitude coordinate when known.', + fieldType: 'number', + required: false, + placeholder: '2.2945', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -45,4 +63,12 @@ export const location: ClassificationSpec = { }, ], defaults: { pluginId: 'location' }, + identity: { + identifies: 'a named place or coordinate point', + ladder: [ + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'geo', source: { kind: 'geohash', precision: 8 } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/mobile-application.ts b/packages/classifications/src/generated/specs/mobile-application.ts index 3f31c02..7c0a921 100644 --- a/packages/classifications/src/generated/specs/mobile-application.ts +++ b/packages/classifications/src/generated/specs/mobile-application.ts @@ -45,6 +45,14 @@ export const mobileApplication: ClassificationSpec = { required: false, placeholder: 'https://apps.apple.com/app/spotify/id324684580', }, + { + key: 'bundleId', + label: 'Bundle / Package ID', + description: 'The store bundle identifier or Android package name.', + fieldType: 'string', + required: false, + placeholder: 'com.spotify.music', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -56,4 +64,19 @@ export const mobileApplication: ClassificationSpec = { }, ], defaults: { pluginId: 'mobile-application', provider: 'opengraph' }, + identity: { + identifies: 'a mobile app per store ecosystem', + ladder: [ + { kind: 'scheme', scheme: 'appid', source: { kind: 'derivation', name: 'appid-bundle' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'downloadUrl' } }, + { + kind: 'gen1', + tag: 3, + recipe: [ + { key: 'name', from: 'field' }, + { key: 'operatingSystem', from: 'field' }, + ], + }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/movie.ts b/packages/classifications/src/generated/specs/movie.ts index ef99413..96d99a1 100644 --- a/packages/classifications/src/generated/specs/movie.ts +++ b/packages/classifications/src/generated/specs/movie.ts @@ -46,4 +46,22 @@ export const movie: ClassificationSpec = { }, ], defaults: { pluginId: 'movie' }, + identity: { + identifies: 'a film (work level)', + ladder: [ + // D27: open registries first — wd (CC0), tmdb (daily ID exports), then imdb (closed) + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'tmdb', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'imdb', source: { kind: 'same-as' } }, + { + kind: 'gen1', + tag: 4, + recipe: [ + { key: 'name', from: 'field' }, + { key: 'yearPublished', from: 'year', of: 'datePublished' }, + ], + }, + { kind: 'gen1', tag: 5, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/music-album.ts b/packages/classifications/src/generated/specs/music-album.ts index 4e836b7..6873ca1 100644 --- a/packages/classifications/src/generated/specs/music-album.ts +++ b/packages/classifications/src/generated/specs/music-album.ts @@ -38,4 +38,13 @@ export const musicAlbum: ClassificationSpec = { }, ], defaults: { pluginId: 'song', provider: 'musicbrainz' }, + identity: { + identifies: 'the release-group (the album as a work)', + ladder: [ + { kind: 'scheme', scheme: 'mbid', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + // D21: byArtist omitted — artist linkage is a triple + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/music-group.ts b/packages/classifications/src/generated/specs/music-group.ts index 36d188e..8151fbe 100644 --- a/packages/classifications/src/generated/specs/music-group.ts +++ b/packages/classifications/src/generated/specs/music-group.ts @@ -29,4 +29,13 @@ export const musicGroup: ClassificationSpec = { }, ], defaults: { pluginId: 'song', provider: 'musicbrainz' }, + identity: { + identifies: 'an artist/band public identity', + ladder: [ + { kind: 'scheme', scheme: 'isni', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'mbid', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/music-recording.ts b/packages/classifications/src/generated/specs/music-recording.ts index 0acafc8..6c240df 100644 --- a/packages/classifications/src/generated/specs/music-recording.ts +++ b/packages/classifications/src/generated/specs/music-recording.ts @@ -36,6 +36,15 @@ export const musicRecording: ClassificationSpec = { required: false, placeholder: 'Discovery', }, + { + key: 'isrc', + schemaProperty: 'isrcCode', + label: 'ISRC', + description: 'The International Standard Recording Code when known.', + fieldType: 'string', + required: false, + placeholder: 'USSM10007459', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -47,4 +56,13 @@ export const musicRecording: ClassificationSpec = { }, ], defaults: { pluginId: 'song', provider: 'musicbrainz' }, + identity: { + identifies: 'the recording (isrc level; compositions are iswc territory)', + ladder: [ + { kind: 'scheme', scheme: 'isrc', source: { kind: 'field', key: 'isrc' } }, + { kind: 'scheme', scheme: 'mbid', source: { kind: 'same-as' } }, + // D21: byArtist omitted (feat.-credit formatting forks); inAlbum was never identity + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/news-article.ts b/packages/classifications/src/generated/specs/news-article.ts index 1adb4d2..a10079f 100644 --- a/packages/classifications/src/generated/specs/news-article.ts +++ b/packages/classifications/src/generated/specs/news-article.ts @@ -55,4 +55,20 @@ export const newsArticle: ClassificationSpec = { }, ], defaults: { pluginId: 'news-article', provider: 'opengraph' }, + identity: { + identifies: 'a news story as published (URL-anchored)', + ladder: [ + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }, + { + kind: 'gen1', + tag: 2, + recipe: [ + { key: 'headline', from: 'field' }, + { key: 'datePublished', from: 'field' }, + ], + }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'headline', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/person.ts b/packages/classifications/src/generated/specs/person.ts index bb81ab2..f3845a3 100644 --- a/packages/classifications/src/generated/specs/person.ts +++ b/packages/classifications/src/generated/specs/person.ts @@ -45,4 +45,21 @@ export const person: ClassificationSpec = { }, ], defaults: { pluginId: 'person', provider: 'wikidata' }, + identity: { + identifies: 'an individual human', + ladder: [ + { kind: 'scheme', scheme: 'isni', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { + kind: 'gen1', + tag: 4, + recipe: [ + { key: 'givenName', from: 'field' }, + { key: 'familyName', from: 'field' }, + ], + }, + // D25: mononyms + { kind: 'gen1', tag: 5, recipe: [{ key: 'givenName', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/podcast-episode.ts b/packages/classifications/src/generated/specs/podcast-episode.ts index f1454a5..697aeec 100644 --- a/packages/classifications/src/generated/specs/podcast-episode.ts +++ b/packages/classifications/src/generated/specs/podcast-episode.ts @@ -45,6 +45,22 @@ export const podcastEpisode: ClassificationSpec = { required: false, placeholder: '2026-02-26', }, + { + key: 'feedGuid', + label: 'Feed GUID', + description: 'The Podcasting 2.0 GUID of the feed this item belongs to.', + fieldType: 'string', + required: false, + placeholder: '917393e3-1b1e-5cef-ace4-edaa54e1f810', + }, + { + key: 'itemGuid', + label: 'Item GUID', + description: 'The RSS value of this episode item within its feed.', + fieldType: 'string', + required: false, + placeholder: 'urn:example:ep42', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -56,4 +72,22 @@ export const podcastEpisode: ClassificationSpec = { }, ], defaults: { pluginId: 'podcast-episode', provider: 'opengraph' }, + identity: { + identifies: + 'one FEED ITEM (manifestation) — the episode-work is the equivalence cluster over feed items (D26)', + ladder: [ + { kind: 'scheme', scheme: 'rssitem', source: { kind: 'derivation', name: 'rss-item' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + // D21: partOfSeries omitted — series linkage is a triple + { + kind: 'gen1', + tag: 3, + recipe: [ + { key: 'name', from: 'field' }, + { key: 'datePublished', from: 'field' }, + ], + }, + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/podcast-series.ts b/packages/classifications/src/generated/specs/podcast-series.ts index a4a115c..68cf981 100644 --- a/packages/classifications/src/generated/specs/podcast-series.ts +++ b/packages/classifications/src/generated/specs/podcast-series.ts @@ -27,6 +27,23 @@ export const podcastSeries: ClassificationSpec = { required: false, placeholder: 'https://www.bankless.com/podcast', }, + { + key: 'feedUrl', + schemaProperty: 'webFeed', + label: 'RSS Feed URL', + description: 'The RSS feed URL; used to derive the Podcasting 2.0 GUID.', + fieldType: 'url', + required: false, + placeholder: 'https://feeds.example.com/show.xml', + }, + { + key: 'podcastGuid', + label: 'Podcast GUID', + description: 'The declared Podcasting 2.0 value; survives feed migrations.', + fieldType: 'string', + required: false, + placeholder: '917393e3-1b1e-5cef-ace4-edaa54e1f810', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -38,4 +55,15 @@ export const podcastSeries: ClassificationSpec = { }, ], defaults: { pluginId: 'podcast-series', provider: 'opengraph' }, + identity: { + identifies: 'the show (feed level)', + ladder: [ + // D26: the declared survives feed migrations + { kind: 'scheme', scheme: 'podcastguid', source: { kind: 'field', key: 'podcastGuid' } }, + // derived from the feed URL — forks on migration, fallback only + { kind: 'scheme', scheme: 'podcastguid', source: { kind: 'podcast-guid', key: 'feedUrl' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/product.ts b/packages/classifications/src/generated/specs/product.ts index 9debeb8..5580ffa 100644 --- a/packages/classifications/src/generated/specs/product.ts +++ b/packages/classifications/src/generated/specs/product.ts @@ -56,4 +56,12 @@ export const product: ClassificationSpec = { }, ], defaults: { pluginId: 'product' }, + identity: { + identifies: 'the product model (not individual units)', + ladder: [ + { kind: 'scheme', scheme: 'gtin', source: { kind: 'field', key: 'gtin' } }, + // D21: brand and SKU omitted — brand linkage is a triple + { kind: 'gen1', tag: 2, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/review.ts b/packages/classifications/src/generated/specs/review.ts index 4bf6a3b..04f79c5 100644 --- a/packages/classifications/src/generated/specs/review.ts +++ b/packages/classifications/src/generated/specs/review.ts @@ -47,4 +47,11 @@ export const review: ClassificationSpec = { }, ], defaults: { pluginId: 'review', provider: 'opengraph' }, + identity: { + identifies: 'one review of one subject (relational entity)', + ladder: [ + // gen1 recipe needs the subject's IID; awaits IID-typed reference fields + { kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/service.ts b/packages/classifications/src/generated/specs/service.ts index a10a2bd..7b99183 100644 --- a/packages/classifications/src/generated/specs/service.ts +++ b/packages/classifications/src/generated/specs/service.ts @@ -47,4 +47,12 @@ export const service: ClassificationSpec = { }, ], defaults: { pluginId: 'service' }, + identity: { + identifies: 'a service offering', + ladder: [ + { kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }, + // D21: provider omitted — provider linkage is a triple + { kind: 'gen1', tag: 2, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/social-media-account.ts b/packages/classifications/src/generated/specs/social-media-account.ts index 485e619..8354f73 100644 --- a/packages/classifications/src/generated/specs/social-media-account.ts +++ b/packages/classifications/src/generated/specs/social-media-account.ts @@ -36,6 +36,22 @@ export const socialMediaAccount: ClassificationSpec = { required: false, placeholder: 'https://x.com/karpathy', }, + { + key: 'platformUserId', + label: 'Platform User ID', + description: 'The immutable platform-assigned user ID (survives handle renames).', + fieldType: 'string', + required: false, + placeholder: '295218901', + }, ], defaults: { pluginId: 'social-media-account' }, + identity: { + identifies: 'an account on a platform (the person behind it is a separate atom)', + ladder: [ + { kind: 'scheme', scheme: 'acct', source: { kind: 'derivation', name: 'acct-strong' } }, + // handles rename and get recycled; ranks below the immutable user id + { kind: 'scheme', scheme: 'acct', source: { kind: 'derivation', name: 'acct-weak' } }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/social-media-posting.ts b/packages/classifications/src/generated/specs/social-media-posting.ts index 550545d..40f502e 100644 --- a/packages/classifications/src/generated/specs/social-media-posting.ts +++ b/packages/classifications/src/generated/specs/social-media-posting.ts @@ -38,4 +38,11 @@ export const socialMediaPosting: ClassificationSpec = { }, ], defaults: { pluginId: 'social-media-posting' }, + identity: { + identifies: 'one post', + ladder: [ + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 2, recipe: [{ key: 'textHash', from: 'text-hash', of: 'text' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/software-application.ts b/packages/classifications/src/generated/specs/software-application.ts index e19d4a9..3920b33 100644 --- a/packages/classifications/src/generated/specs/software-application.ts +++ b/packages/classifications/src/generated/specs/software-application.ts @@ -63,4 +63,11 @@ export const softwareApplication: ClassificationSpec = { }, ], defaults: { pluginId: 'software-application', provider: 'opengraph' }, + identity: { + identifies: 'an end-user application', + ladder: [ + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }, + { kind: 'gen1', tag: 4, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/software.ts b/packages/classifications/src/generated/specs/software.ts index 1e7cb79..c32e9e0 100644 --- a/packages/classifications/src/generated/specs/software.ts +++ b/packages/classifications/src/generated/specs/software.ts @@ -27,6 +27,14 @@ export const software: ClassificationSpec = { required: true, placeholder: 'https://github.com/0xintuition/intuition-data-structure', }, + { + key: 'packageUrl', + label: 'Package URL', + description: 'The purl package coordinate, version-free.', + fieldType: 'string', + required: false, + placeholder: 'pkg:npm/react', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -38,4 +46,13 @@ export const software: ClassificationSpec = { }, ], defaults: { pluginId: 'software', provider: 'github' }, + identity: { + identifies: + 'the package/project, version-free — purl (open spec) first; repo URLs migrate (F5)', + ladder: [ + { kind: 'scheme', scheme: 'purl', source: { kind: 'field', key: 'packageUrl' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'codeRepository' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/thing.ts b/packages/classifications/src/generated/specs/thing.ts index d257523..42d7bf2 100644 --- a/packages/classifications/src/generated/specs/thing.ts +++ b/packages/classifications/src/generated/specs/thing.ts @@ -38,4 +38,12 @@ export const thing: ClassificationSpec = { }, ], defaults: { pluginId: 'thing', provider: 'wikidata' }, + identity: { + identifies: 'anything not covered by a specific classification (weakest IDs by design)', + ladder: [ + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/tv-series.ts b/packages/classifications/src/generated/specs/tv-series.ts index afa6bf6..045d4d9 100644 --- a/packages/classifications/src/generated/specs/tv-series.ts +++ b/packages/classifications/src/generated/specs/tv-series.ts @@ -47,4 +47,22 @@ export const tvSeries: ClassificationSpec = { }, ], defaults: { pluginId: 'tv-series' }, + identity: { + identifies: 'the series (seasons/episodes are unmodeled levels)', + ladder: [ + // D27: open registries first + { kind: 'scheme', scheme: 'wd', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'tmdb', source: { kind: 'same-as' } }, + { kind: 'scheme', scheme: 'imdb', source: { kind: 'same-as' } }, + { + kind: 'gen1', + tag: 4, + recipe: [ + { key: 'name', from: 'field' }, + { key: 'startYear', from: 'year', of: 'startDate' }, + ], + }, + { kind: 'gen1', tag: 5, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/video-object.ts b/packages/classifications/src/generated/specs/video-object.ts index 2a30d90..14fa61b 100644 --- a/packages/classifications/src/generated/specs/video-object.ts +++ b/packages/classifications/src/generated/specs/video-object.ts @@ -45,6 +45,14 @@ export const videoObject: ClassificationSpec = { required: false, placeholder: 'https://example.com/videos/how-intuition-works', }, + { + key: 'contentHash', + label: 'Content Hash', + description: 'Hash of the video bytes as alg:hex, e.g. sha256:.', + fieldType: 'string', + required: false, + placeholder: 'sha256:9f86d081884c7d65…', + }, { key: 'sameAs', schemaProperty: 'sameAs', @@ -56,4 +64,13 @@ export const videoObject: ClassificationSpec = { }, ], defaults: { pluginId: 'video-object' }, + identity: { + identifies: 'a video — byte-identity first; platform URLs are manifestation fallbacks (F4)', + ladder: [ + { kind: 'scheme', scheme: 'hash', source: { kind: 'field', key: 'contentHash' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'contentUrl' } }, + { kind: 'scheme', scheme: 'url', source: { kind: 'same-as' } }, + { kind: 'gen1', tag: 3, recipe: [{ key: 'name', from: 'field' }] }, + ], + }, }; diff --git a/packages/classifications/src/generated/specs/web-page.ts b/packages/classifications/src/generated/specs/web-page.ts index ce56053..ac0b758 100644 --- a/packages/classifications/src/generated/specs/web-page.ts +++ b/packages/classifications/src/generated/specs/web-page.ts @@ -47,4 +47,8 @@ export const webPage: ClassificationSpec = { }, ], defaults: { pluginId: 'web-page', provider: 'opengraph' }, + identity: { + identifies: 'a page at a canonical URL', + ladder: [{ kind: 'scheme', scheme: 'url', source: { kind: 'field', key: 'url' } }], + }, }; diff --git a/packages/classifications/src/generated/specs/web-site.ts b/packages/classifications/src/generated/specs/web-site.ts index bba3b3c..b6eafb7 100644 --- a/packages/classifications/src/generated/specs/web-site.ts +++ b/packages/classifications/src/generated/specs/web-site.ts @@ -38,4 +38,8 @@ export const webSite: ClassificationSpec = { }, ], defaults: { pluginId: 'web-site', provider: 'opengraph' }, + identity: { + identifies: 'a site (origin level)', + ladder: [{ kind: 'scheme', scheme: 'url', source: { kind: 'url-origin', key: 'url' } }], + }, }; diff --git a/packages/classifications/src/identity.test.ts b/packages/classifications/src/identity.test.ts new file mode 100644 index 0000000..6a77529 --- /dev/null +++ b/packages/classifications/src/identity.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { CLASSIFICATION_SPECS } from './classifications.js'; +import { + IDENTITY_SCHEME_CLASS, + IDENTITY_SCHEME_TYPING, + rungClass, + topRungAnchorEligible, +} from './identity.js'; +import type { ClassificationSpec, IdentityRung, IdentityValueSource } from './types.js'; + +const CLASS_ORDER = { A: 0, B: 1, C: 2 } as const; + +function specFieldKeys(spec: ClassificationSpec): Set { + return new Set(spec.fields.map((field) => field.key)); +} + +/** Every spec field a declarative source reads, or null when engine-owned. */ +function sourceFieldKeys(source: IdentityValueSource): readonly string[] | null { + switch (source.kind) { + case 'field': + case 'url-origin': + case 'podcast-guid': + return [source.key]; + case 'same-as': + return ['sameAs']; + case 'geohash': + return ['latitude', 'longitude']; + case 'derivation': + // Derivation input contracts are engine-owned; covered below. + return DERIVATION_INPUTS[source.name] ?? null; + } +} + +/** The spec fields each named derivation reads (scheme-registry.md). */ +const DERIVATION_INPUTS: Record = { + 'acct-strong': ['platform', 'platformUserId'], + 'acct-weak': ['platform', 'username'], + 'appid-bundle': ['operatingSystem', 'bundleId'], + 'rss-item': ['feedGuid', 'itemGuid'], + 'termset-term': ['inDefinedTermSet', 'termCode'], + 'caip10-eoa': ['address'], + 'caip10-contract': ['chainId', 'address'], + 'caip19-erc20': ['chainId', 'address'], +}; + +describe('identity ladders', () => { + it('every classification declares identity (or explicit null)', () => { + for (const spec of CLASSIFICATION_SPECS) { + expect(spec.identity !== undefined, `${spec.slug} must declare identity`).toBe(true); + } + }); + + it('only aggregate-rating is outside IID scope (D23)', () => { + const outOfScope = CLASSIFICATION_SPECS.filter((spec) => spec.identity === null).map( + (spec) => spec.slug + ); + expect(outOfScope).toEqual(['aggregate-rating']); + }); + + it('ladders are non-empty and ordered A ≥ B ≥ C', () => { + for (const spec of CLASSIFICATION_SPECS) { + if (!spec.identity) continue; + expect(spec.identity.ladder.length, `${spec.slug} ladder is empty`).toBeGreaterThan(0); + const order = spec.identity.ladder.map((rung) => CLASS_ORDER[rungClass(rung)]); + for (let index = 1; index < order.length; index++) { + expect( + order[index] >= order[index - 1], + `${spec.slug}: rung ${index} (${order[index]}) outranks rung ${index - 1}` + ).toBe(true); + } + } + }); + + it('every source reads only fields the classification declares', () => { + for (const spec of CLASSIFICATION_SPECS) { + if (!spec.identity) continue; + const declared = specFieldKeys(spec); + for (const rung of spec.identity.ladder) { + if (rung.kind !== 'scheme') continue; + const keys = sourceFieldKeys(rung.source); + expect( + keys, + `${spec.slug}: unknown derivation in ${JSON.stringify(rung.source)}` + ).not.toBeNull(); + for (const key of keys ?? []) { + expect(declared.has(key), `${spec.slug}: source reads undeclared field "${key}"`).toBe( + true + ); + } + } + } + }); + + it('gen1 recipes reference declared fields only (D21 surface)', () => { + for (const spec of CLASSIFICATION_SPECS) { + if (!spec.identity) continue; + const declared = specFieldKeys(spec); + for (const rung of spec.identity.ladder) { + if (rung.kind !== 'gen1') continue; + expect(rung.recipe.length, `${spec.slug} gen1 r${rung.tag}: empty recipe`).toBeGreaterThan( + 0 + ); + for (const recipeField of rung.recipe) { + const reads = + recipeField.from === 'field' + ? recipeField.key + : recipeField.from === 'geohash' + ? null // reads latitude/longitude, checked via declared coords below + : recipeField.of; + if (reads !== null) { + expect( + declared.has(reads), + `${spec.slug} gen1 r${rung.tag}: recipe reads undeclared field "${reads}"` + ).toBe(true); + } else { + expect( + declared.has('latitude') && declared.has('longitude'), + `${spec.slug}: geohash recipe needs coords` + ).toBe(true); + } + } + } + } + }); + + it('gen1 rung tags are unique per ladder (D22)', () => { + for (const spec of CLASSIFICATION_SPECS) { + if (!spec.identity) continue; + const tags = spec.identity.ladder + .filter((rung): rung is Extract => rung.kind === 'gen1') + .map((rung) => rung.tag); + expect(new Set(tags).size, `${spec.slug}: duplicate gen1 tags`).toBe(tags.length); + } + }); + + it('scheme registry maps cover every scheme exactly once', () => { + expect(Object.keys(IDENTITY_SCHEME_CLASS).sort()).toEqual( + Object.keys(IDENTITY_SCHEME_TYPING).sort() + ); + for (const spec of CLASSIFICATION_SPECS) { + if (!spec.identity) continue; + for (const rung of spec.identity.ladder) { + if (rung.kind === 'scheme') { + expect( + IDENTITY_SCHEME_CLASS[rung.scheme], + `${spec.slug}: unregistered scheme` + ).toBeDefined(); + } + } + } + }); + + it('anchor eligibility matches D30 expectations for the flagship lanes', () => { + const bySlug = new Map(CLASSIFICATION_SPECS.map((spec) => [spec.slug, spec])); + // Unambiguous Class A top rungs → P0 anchors. + for (const slug of ['music-recording', 'book', 'product', 'ethereum-erc20']) { + expect(topRungAnchorEligible(bySlug.get(slug) as ClassificationSpec), slug).toBe(true); + } + // Polymorphic or Class C top rungs → floored at P1. + for (const slug of ['person', 'thing', 'web-page', 'ethereum-account']) { + expect(topRungAnchorEligible(bySlug.get(slug) as ClassificationSpec), slug).toBe(false); + } + }); +}); diff --git a/packages/classifications/src/identity.ts b/packages/classifications/src/identity.ts new file mode 100644 index 0000000..8550208 --- /dev/null +++ b/packages/classifications/src/identity.ts @@ -0,0 +1,104 @@ +/** + * Scheme registry metadata for the identity ladders (IID spec §3, D30). + * + * The ladders themselves live on each generated `ClassificationSpec` as + * declarative data. This module carries the per-scheme facts consumers and + * tests need without a derivation engine: the identity class of every + * scheme, and whether a bare IID of that scheme names its classification + * (anchor eligibility, D30). + * + * Source of truth: intuition-v2 `.planning/intuition-id/scheme-registry.md` + * and `@0xintuition/iid` `SCHEMES` / `SCHEME_TYPING`. + */ +import type { + ClassificationSpec, + IdentityClass, + IdentityRung, + IdentitySchemeName, +} from './types.js'; + +/** IID spec §3 — Class A registered authority, B intrinsic key, C derived. */ +export const IDENTITY_SCHEME_CLASS: Readonly> = { + isbn: 'A', + isrc: 'A', + iswc: 'A', + isni: 'A', + orcid: 'A', + lei: 'A', + gtin: 'A', + doi: 'A', + eidr: 'A', + wd: 'A', + mbid: 'A', + olid: 'A', + imdb: 'A', + tmdb: 'A', + podcastguid: 'A', + url: 'B', + caip10: 'B', + caip19: 'B', + hash: 'B', + appid: 'B', + purl: 'B', + geo: 'B', + acct: 'B', + rssitem: 'B', + termset: 'B', + gen1: 'C', +}; + +/** + * D30 scheme typing: a P0 anchor (atom data = the bare IID string) is only + * legal when the scheme implies the entity's classification. Polymorphic + * schemes floor at P1, where `@type` lives in the payload. + */ +export const IDENTITY_SCHEME_TYPING: Readonly< + Record +> = { + isbn: 'unambiguous', + isrc: 'unambiguous', + iswc: 'unambiguous', + isni: 'polymorphic', // persons AND bands/orgs + orcid: 'polymorphic', // kept symmetric with isni + lei: 'unambiguous', + gtin: 'unambiguous', + doi: 'polymorphic', // articles, datasets, film + eidr: 'unambiguous', + wd: 'polymorphic', + mbid: 'unambiguous', // type segment in-value + olid: 'unambiguous', // W/M/A suffix in-value + imdb: 'polymorphic', + tmdb: 'polymorphic', + podcastguid: 'unambiguous', + url: 'polymorphic', + caip10: 'polymorphic', // account OR contract + caip19: 'unambiguous', + hash: 'polymorphic', + appid: 'unambiguous', + purl: 'unambiguous', + geo: 'polymorphic', + acct: 'unambiguous', + rssitem: 'unambiguous', + termset: 'unambiguous', + gen1: 'unambiguous', // slug in-value (still never P0 — Class C floors at P1) +}; + +/** The identity class a rung asserts (gen1 rungs are Class C by definition). */ +export function rungClass(rung: IdentityRung): IdentityClass { + return rung.kind === 'gen1' ? 'C' : IDENTITY_SCHEME_CLASS[rung.scheme]; +} + +/** + * D29/D30: may this classification's STRONGEST rung mint as a P0 anchor? + * True only when that rung is Class A/B on an unambiguous scheme. Class C + * recipe fields are preimage evidence and must travel in a P1 payload. + */ +export function topRungAnchorEligible(spec: ClassificationSpec): boolean { + const top = spec.identity?.ladder[0]; + + if (!top || top.kind === 'gen1') { + return false; + } + + return IDENTITY_SCHEME_TYPING[top.scheme] === 'unambiguous'; +} diff --git a/packages/classifications/src/index.ts b/packages/classifications/src/index.ts index 7d7ddec..d3fdd30 100644 --- a/packages/classifications/src/index.ts +++ b/packages/classifications/src/index.ts @@ -17,6 +17,12 @@ export type { CreationProfileClassification, CreationRelationship, } from './creation-profile.js'; +export { + IDENTITY_SCHEME_CLASS, + IDENTITY_SCHEME_TYPING, + rungClass, + topRungAnchorEligible, +} from './identity.js'; export type { ExpectedObject, MetadataPredicateMatrixEntry, @@ -37,5 +43,12 @@ export type { ClassificationValidationIssue, ClassificationValueMap, FieldType, + IdentityClass, + IdentityDerivationName, + IdentityRecipeField, + IdentityRung, + IdentitySchemeName, + IdentitySpec, + IdentityValueSource, PredicateKeyReference, } from './types.js'; diff --git a/packages/classifications/src/types.ts b/packages/classifications/src/types.ts index 6bae3d3..5c9c1e7 100644 --- a/packages/classifications/src/types.ts +++ b/packages/classifications/src/types.ts @@ -29,6 +29,104 @@ export type ClassificationCategory = | 'Blockchain' | 'Other'; +// --- Identity (Intuition ID spec) ----------------------------------------- +// +// Each classification declares an ordered identity ladder (IID spec §4): +// strongest identification first. Minting derives the `identifier` from the +// highest rung whose data is available. Ladders are DECLARATIVE data — a +// derivation engine interprets the sources; nothing here executes. +// +// Ladder rules (source of truth: intuition-v2 `.planning/intuition-id/`): +// - D21: gen1 recipes never hash free-text names of RELATED entities +// (author, byArtist, brand, employer) — relationships are triples. +// - D22: gen1 rung tags are stable recipe identifiers, frozen forever. +// Inserting a rung assigns a fresh tag; tags never renumber. +// - Rungs are all-or-nothing: a rung fires only when every input it names +// is present and canonicalizes. + +/** How strongly a scheme pins its entity (IID spec §3). */ +export type IdentityClass = 'A' | 'B' | 'C'; + +/** The governed scheme registry (IID scheme-registry.md). */ +export type IdentitySchemeName = + | 'isbn' + | 'isrc' + | 'iswc' + | 'isni' + | 'orcid' + | 'lei' + | 'gtin' + | 'doi' + | 'eidr' + | 'wd' + | 'mbid' + | 'olid' + | 'imdb' + | 'tmdb' + | 'podcastguid' + | 'url' + | 'caip10' + | 'caip19' + | 'hash' + | 'appid' + | 'purl' + | 'geo' + | 'acct' + | 'rssitem' + | 'termset' + | 'gen1'; + +/** + * Named multi-field derivations. Each name is a frozen contract the + * derivation engine implements (value shapes per scheme-registry.md). + */ +export type IdentityDerivationName = + | 'acct-strong' // : — immutable platform user id + | 'acct-weak' // :@ — handles rename/recycle; ranks lower + | 'appid-bundle' // : + | 'rss-item' // : + | 'termset-term' // : + | 'caip10-eoa' // eip155:1:
— D24: EOAs are chain-agnostic + | 'caip10-contract' // eip155::
+ | 'caip19-erc20'; // eip155:/erc20:
+ +/** Where a scheme rung reads its raw value. */ +export type IdentityValueSource = + | { readonly kind: 'field'; readonly key: string } + | { readonly kind: 'same-as' } // scan sameAs URLs through the scheme's canonicalizer + | { readonly kind: 'url-origin'; readonly key: string } // origin of a URL field + | { readonly kind: 'geohash'; readonly precision: number } // from latitude + longitude + | { readonly kind: 'podcast-guid'; readonly key: string } // RFC 4122 v5 over a feed URL + | { readonly kind: 'derivation'; readonly name: IdentityDerivationName }; + +/** One hashed input of a gen1 recipe. `key` is the preimage key (spec §5.1). */ +export type IdentityRecipeField = + | { readonly key: string; readonly from: 'field' } // NORM-1(field value); field key === preimage key + | { readonly key: string; readonly from: 'year'; readonly of: string } // YYYY of an ISO date field + | { readonly key: string; readonly from: 'text-hash'; readonly of: string } // keccak16(NORM-1(value)) + | { readonly key: string; readonly from: 'geohash'; readonly precision: number }; + +export type IdentityRung = + | { + readonly kind: 'scheme'; + readonly scheme: Exclude; + readonly source: IdentityValueSource; + readonly note?: string; + } + | { + readonly kind: 'gen1'; + readonly tag: number; // D22: stable recipe id — frozen, never renumbered + readonly recipe: readonly IdentityRecipeField[]; + readonly note?: string; + }; + +export interface IdentitySpec { + /** The ontological level this classification identifies (one atom per level). */ + readonly identifies: string; + /** Ordered strongest-first: Class A ≥ B ≥ C. */ + readonly ladder: readonly IdentityRung[]; +} + export interface ClassificationSpec { slug: string; type: string; @@ -45,6 +143,12 @@ export interface ClassificationSpec { pluginId: string; provider?: string; }; + /** + * The formalized identity spec, or null when the classification is + * deliberately outside IID scope (e.g. aggregate-rating, remodeled as a + * triple — D23). + */ + identity: IdentitySpec | null; } export type ClassificationValueMap = Record; diff --git a/packages/predicates/src/generated/specs/actor.ts b/packages/predicates/src/generated/specs/actor.ts index bb71964..b586341 100644 --- a/packages/predicates/src/generated/specs/actor.ts +++ b/packages/predicates/src/generated/specs/actor.ts @@ -8,4 +8,7 @@ export const actor = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/affiliatedWith.ts b/packages/predicates/src/generated/specs/affiliatedWith.ts index cb66f41..e411d4e 100644 --- a/packages/predicates/src/generated/specs/affiliatedWith.ts +++ b/packages/predicates/src/generated/specs/affiliatedWith.ts @@ -10,4 +10,7 @@ export const affiliatedWith = { category: 'Affiliation/Membership', status: 'proposed', isSymmetric: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/agreeWith.ts b/packages/predicates/src/generated/specs/agreeWith.ts index 565aa89..3650237 100644 --- a/packages/predicates/src/generated/specs/agreeWith.ts +++ b/packages/predicates/src/generated/specs/agreeWith.ts @@ -9,4 +9,9 @@ export const agreeWith = { thirdPerson: 'agrees with', category: 'Sentiment/Opinion', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['disagreeWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/alternativeTo.ts b/packages/predicates/src/generated/specs/alternativeTo.ts index 5fd39ab..1331d96 100644 --- a/packages/predicates/src/generated/specs/alternativeTo.ts +++ b/packages/predicates/src/generated/specs/alternativeTo.ts @@ -9,4 +9,7 @@ export const alternativeTo = { category: 'Curation/Containment', status: 'enshrined', isSymmetric: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/alumniOf.ts b/packages/predicates/src/generated/specs/alumniOf.ts index e16744e..8857a1a 100644 --- a/packages/predicates/src/generated/specs/alumniOf.ts +++ b/packages/predicates/src/generated/specs/alumniOf.ts @@ -8,4 +8,8 @@ export const alumniOf = { conjugates: false, category: 'Affiliation/Membership', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/areaServed.ts b/packages/predicates/src/generated/specs/areaServed.ts index 06519a3..a67238f 100644 --- a/packages/predicates/src/generated/specs/areaServed.ts +++ b/packages/predicates/src/generated/specs/areaServed.ts @@ -8,4 +8,7 @@ export const areaServed = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/attestedBy.ts b/packages/predicates/src/generated/specs/attestedBy.ts index fdf1dfd..3ce7d4e 100644 --- a/packages/predicates/src/generated/specs/attestedBy.ts +++ b/packages/predicates/src/generated/specs/attestedBy.ts @@ -8,4 +8,7 @@ export const attestedBy = { conjugates: false, category: 'Provenance/Evidence', status: 'proposed', + objectKind: 'claim', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/auditedBy.ts b/packages/predicates/src/generated/specs/auditedBy.ts index 197f6ba..c4a5ba7 100644 --- a/packages/predicates/src/generated/specs/auditedBy.ts +++ b/packages/predicates/src/generated/specs/auditedBy.ts @@ -9,4 +9,7 @@ export const auditedBy = { conjugates: false, category: 'Provenance/Evidence', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/authoredBy.ts b/packages/predicates/src/generated/specs/authoredBy.ts index a34bc57..8eab545 100644 --- a/packages/predicates/src/generated/specs/authoredBy.ts +++ b/packages/predicates/src/generated/specs/authoredBy.ts @@ -8,4 +8,7 @@ export const authoredBy = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/availableOn.ts b/packages/predicates/src/generated/specs/availableOn.ts index 24ecc1d..fcb6965 100644 --- a/packages/predicates/src/generated/specs/availableOn.ts +++ b/packages/predicates/src/generated/specs/availableOn.ts @@ -9,4 +9,7 @@ export const availableOn = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/backedBy.ts b/packages/predicates/src/generated/specs/backedBy.ts index bd1f911..63d50ab 100644 --- a/packages/predicates/src/generated/specs/backedBy.ts +++ b/packages/predicates/src/generated/specs/backedBy.ts @@ -9,4 +9,8 @@ export const backedBy = { conjugates: false, category: 'Economic/Market', status: 'proposed', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/bearishOn.ts b/packages/predicates/src/generated/specs/bearishOn.ts index ca65f06..1e4df26 100644 --- a/packages/predicates/src/generated/specs/bearishOn.ts +++ b/packages/predicates/src/generated/specs/bearishOn.ts @@ -9,4 +9,9 @@ export const bearishOn = { conjugates: false, category: 'Sentiment/Opinion', status: 'enshrined', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['bullishOn'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/betterThan.ts b/packages/predicates/src/generated/specs/betterThan.ts index c74df91..b71949e 100644 --- a/packages/predicates/src/generated/specs/betterThan.ts +++ b/packages/predicates/src/generated/specs/betterThan.ts @@ -9,4 +9,8 @@ export const betterThan = { conjugates: false, category: 'Comparison/Ranking', status: 'enshrined', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'evaluative', + contradicts: ['worseThan'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/blocked.ts b/packages/predicates/src/generated/specs/blocked.ts index ee376f3..2781359 100644 --- a/packages/predicates/src/generated/specs/blocked.ts +++ b/packages/predicates/src/generated/specs/blocked.ts @@ -8,4 +8,8 @@ export const blocked = { conjugates: false, category: 'Social/Reputation', status: 'proposed', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'state', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/branchOf.ts b/packages/predicates/src/generated/specs/branchOf.ts index 6ac9477..6541ab8 100644 --- a/packages/predicates/src/generated/specs/branchOf.ts +++ b/packages/predicates/src/generated/specs/branchOf.ts @@ -9,4 +9,8 @@ export const branchOf = { category: 'Affiliation/Membership', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/brand.ts b/packages/predicates/src/generated/specs/brand.ts index 0dd44d2..f1a8930 100644 --- a/packages/predicates/src/generated/specs/brand.ts +++ b/packages/predicates/src/generated/specs/brand.ts @@ -9,4 +9,7 @@ export const brand = { category: 'Identity/Classification', status: 'proposed', examples: ['(iPhone, brand, Apple)', '(AWS, brand, Amazon Web Services)'], + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/bullishOn.ts b/packages/predicates/src/generated/specs/bullishOn.ts index aa025db..a17b714 100644 --- a/packages/predicates/src/generated/specs/bullishOn.ts +++ b/packages/predicates/src/generated/specs/bullishOn.ts @@ -9,4 +9,9 @@ export const bullishOn = { conjugates: false, category: 'Sentiment/Opinion', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['bearishOn'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/byArtist.ts b/packages/predicates/src/generated/specs/byArtist.ts index af76a23..5b3b712 100644 --- a/packages/predicates/src/generated/specs/byArtist.ts +++ b/packages/predicates/src/generated/specs/byArtist.ts @@ -8,4 +8,7 @@ export const byArtist = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/certifiedBy.ts b/packages/predicates/src/generated/specs/certifiedBy.ts index e0fec35..91ef16d 100644 --- a/packages/predicates/src/generated/specs/certifiedBy.ts +++ b/packages/predicates/src/generated/specs/certifiedBy.ts @@ -9,4 +9,7 @@ export const certifiedBy = { conjugates: false, category: 'Knowledge/Expertise', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/citedBy.ts b/packages/predicates/src/generated/specs/citedBy.ts index 9ffbebd..15bd745 100644 --- a/packages/predicates/src/generated/specs/citedBy.ts +++ b/packages/predicates/src/generated/specs/citedBy.ts @@ -10,4 +10,8 @@ export const citedBy = { category: 'Provenance/Evidence', status: 'proposed', inversePredicate: 'reference', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'reference', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/compatibleWith.ts b/packages/predicates/src/generated/specs/compatibleWith.ts index 68a9cc8..c1cd987 100644 --- a/packages/predicates/src/generated/specs/compatibleWith.ts +++ b/packages/predicates/src/generated/specs/compatibleWith.ts @@ -10,4 +10,7 @@ export const compatibleWith = { category: 'Domain-Specific', status: 'proposed', isSymmetric: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/competeWith.ts b/packages/predicates/src/generated/specs/competeWith.ts index 6ef7cf7..9232682 100644 --- a/packages/predicates/src/generated/specs/competeWith.ts +++ b/packages/predicates/src/generated/specs/competeWith.ts @@ -10,4 +10,7 @@ export const competeWith = { category: 'Comparison/Ranking', status: 'proposed', isSymmetric: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/compliantWith.ts b/packages/predicates/src/generated/specs/compliantWith.ts index c3c38a8..8b63c05 100644 --- a/packages/predicates/src/generated/specs/compliantWith.ts +++ b/packages/predicates/src/generated/specs/compliantWith.ts @@ -9,4 +9,7 @@ export const compliantWith = { conjugates: false, category: 'Governance/Policy', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/confirmedBy.ts b/packages/predicates/src/generated/specs/confirmedBy.ts index d39dad6..20fb0bd 100644 --- a/packages/predicates/src/generated/specs/confirmedBy.ts +++ b/packages/predicates/src/generated/specs/confirmedBy.ts @@ -8,4 +8,7 @@ export const confirmedBy = { conjugates: false, category: 'Provenance/Evidence', status: 'proposed', + objectKind: 'claim', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/contain.ts b/packages/predicates/src/generated/specs/contain.ts index 918ad26..c677802 100644 --- a/packages/predicates/src/generated/specs/contain.ts +++ b/packages/predicates/src/generated/specs/contain.ts @@ -11,4 +11,8 @@ export const contain = { status: 'enshrined', isHierarchical: true, inversePredicate: 'listed in', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + inverse: 'listedIn', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/containedInPlace.ts b/packages/predicates/src/generated/specs/containedInPlace.ts index 16426d4..9534ce7 100644 --- a/packages/predicates/src/generated/specs/containedInPlace.ts +++ b/packages/predicates/src/generated/specs/containedInPlace.ts @@ -11,4 +11,9 @@ export const containedInPlace = { isTransitive: true, isHierarchical: true, inversePredicate: 'contains place', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'containsPlace', + specializes: ['locatedIn'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/containsPlace.ts b/packages/predicates/src/generated/specs/containsPlace.ts index 3267403..4593a91 100644 --- a/packages/predicates/src/generated/specs/containsPlace.ts +++ b/packages/predicates/src/generated/specs/containsPlace.ts @@ -11,4 +11,8 @@ export const containsPlace = { isTransitive: true, isHierarchical: true, inversePredicate: 'contained in place', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'containedInPlace', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/contributedTo.ts b/packages/predicates/src/generated/specs/contributedTo.ts index 493306e..f9473d4 100644 --- a/packages/predicates/src/generated/specs/contributedTo.ts +++ b/packages/predicates/src/generated/specs/contributedTo.ts @@ -9,4 +9,7 @@ export const contributedTo = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/createdBy.ts b/packages/predicates/src/generated/specs/createdBy.ts index 16adc00..32ac5de 100644 --- a/packages/predicates/src/generated/specs/createdBy.ts +++ b/packages/predicates/src/generated/specs/createdBy.ts @@ -8,4 +8,7 @@ export const createdBy = { conjugates: false, category: 'Authorship/Contribution', status: 'enshrined', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/curatedBy.ts b/packages/predicates/src/generated/specs/curatedBy.ts index dc20a4e..bb5edea 100644 --- a/packages/predicates/src/generated/specs/curatedBy.ts +++ b/packages/predicates/src/generated/specs/curatedBy.ts @@ -9,4 +9,7 @@ export const curatedBy = { conjugates: false, category: 'Curation/Containment', status: 'enshrined', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/delegatedTo.ts b/packages/predicates/src/generated/specs/delegatedTo.ts index 65a101c..52f3694 100644 --- a/packages/predicates/src/generated/specs/delegatedTo.ts +++ b/packages/predicates/src/generated/specs/delegatedTo.ts @@ -9,4 +9,7 @@ export const delegatedTo = { conjugates: false, category: 'Governance/Policy', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/dependOn.ts b/packages/predicates/src/generated/specs/dependOn.ts index 590fa8d..d9402fd 100644 --- a/packages/predicates/src/generated/specs/dependOn.ts +++ b/packages/predicates/src/generated/specs/dependOn.ts @@ -10,4 +10,7 @@ export const dependOn = { category: 'Curation/Containment', status: 'proposed', isTransitive: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/deprecatedBy.ts b/packages/predicates/src/generated/specs/deprecatedBy.ts index ec98496..eee13cd 100644 --- a/packages/predicates/src/generated/specs/deprecatedBy.ts +++ b/packages/predicates/src/generated/specs/deprecatedBy.ts @@ -9,4 +9,7 @@ export const deprecatedBy = { conjugates: false, category: 'Temporal/Lifecycle', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/derivedFrom.ts b/packages/predicates/src/generated/specs/derivedFrom.ts index 1afde20..48fe89a 100644 --- a/packages/predicates/src/generated/specs/derivedFrom.ts +++ b/packages/predicates/src/generated/specs/derivedFrom.ts @@ -8,4 +8,7 @@ export const derivedFrom = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/director.ts b/packages/predicates/src/generated/specs/director.ts index 7c3cfd5..58d0d23 100644 --- a/packages/predicates/src/generated/specs/director.ts +++ b/packages/predicates/src/generated/specs/director.ts @@ -8,4 +8,7 @@ export const director = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/disagreeWith.ts b/packages/predicates/src/generated/specs/disagreeWith.ts index 686df63..0287ff5 100644 --- a/packages/predicates/src/generated/specs/disagreeWith.ts +++ b/packages/predicates/src/generated/specs/disagreeWith.ts @@ -9,4 +9,9 @@ export const disagreeWith = { thirdPerson: 'disagrees with', category: 'Sentiment/Opinion', status: 'enshrined', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['agreeWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/disputedBy.ts b/packages/predicates/src/generated/specs/disputedBy.ts index 6880d4f..3326db1 100644 --- a/packages/predicates/src/generated/specs/disputedBy.ts +++ b/packages/predicates/src/generated/specs/disputedBy.ts @@ -9,4 +9,7 @@ export const disputedBy = { conjugates: false, category: 'Provenance/Evidence', status: 'proposed', + objectKind: 'claim', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/distrust.ts b/packages/predicates/src/generated/specs/distrust.ts index 9943f54..64281f5 100644 --- a/packages/predicates/src/generated/specs/distrust.ts +++ b/packages/predicates/src/generated/specs/distrust.ts @@ -10,4 +10,9 @@ export const distrust = { thirdPerson: 'distrusts', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['trust'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/employedBy.ts b/packages/predicates/src/generated/specs/employedBy.ts index 3d8a8b4..dfa6f50 100644 --- a/packages/predicates/src/generated/specs/employedBy.ts +++ b/packages/predicates/src/generated/specs/employedBy.ts @@ -8,4 +8,8 @@ export const employedBy = { conjugates: false, category: 'Affiliation/Membership', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/enabledBy.ts b/packages/predicates/src/generated/specs/enabledBy.ts index 698eeb3..bdcaa5f 100644 --- a/packages/predicates/src/generated/specs/enabledBy.ts +++ b/packages/predicates/src/generated/specs/enabledBy.ts @@ -9,4 +9,7 @@ export const enabledBy = { conjugates: false, category: 'Temporal/Lifecycle', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/endorse.ts b/packages/predicates/src/generated/specs/endorse.ts index be13f92..4000509 100644 --- a/packages/predicates/src/generated/specs/endorse.ts +++ b/packages/predicates/src/generated/specs/endorse.ts @@ -10,4 +10,9 @@ export const endorse = { thirdPerson: 'endorses', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', + specializes: ['support'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/equivalentTo.ts b/packages/predicates/src/generated/specs/equivalentTo.ts index a89f0e5..4862a31 100644 --- a/packages/predicates/src/generated/specs/equivalentTo.ts +++ b/packages/predicates/src/generated/specs/equivalentTo.ts @@ -11,4 +11,7 @@ export const equivalentTo = { status: 'proposed', isTransitive: true, isSymmetric: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/evidencedBy.ts b/packages/predicates/src/generated/specs/evidencedBy.ts index 19c0cf5..36be97d 100644 --- a/packages/predicates/src/generated/specs/evidencedBy.ts +++ b/packages/predicates/src/generated/specs/evidencedBy.ts @@ -8,4 +8,7 @@ export const evidencedBy = { conjugates: false, category: 'Provenance/Evidence', status: 'proposed', + objectKind: 'claim', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/expertIn.ts b/packages/predicates/src/generated/specs/expertIn.ts index b02ab84..c9e5365 100644 --- a/packages/predicates/src/generated/specs/expertIn.ts +++ b/packages/predicates/src/generated/specs/expertIn.ts @@ -9,4 +9,7 @@ export const expertIn = { conjugates: false, category: 'Knowledge/Expertise', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/featuredIn.ts b/packages/predicates/src/generated/specs/featuredIn.ts index 5c8d898..e07cbfe 100644 --- a/packages/predicates/src/generated/specs/featuredIn.ts +++ b/packages/predicates/src/generated/specs/featuredIn.ts @@ -9,4 +9,7 @@ export const featuredIn = { category: 'Curation/Containment', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/follow.ts b/packages/predicates/src/generated/specs/follow.ts index 2b45f8a..6fd00da 100644 --- a/packages/predicates/src/generated/specs/follow.ts +++ b/packages/predicates/src/generated/specs/follow.ts @@ -10,4 +10,7 @@ export const follow = { thirdPerson: 'follows', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/followedBy.ts b/packages/predicates/src/generated/specs/followedBy.ts index 724a25c..53b928c 100644 --- a/packages/predicates/src/generated/specs/followedBy.ts +++ b/packages/predicates/src/generated/specs/followedBy.ts @@ -11,4 +11,8 @@ export const followedBy = { status: 'proposed', isTransitive: true, inversePredicate: 'preceded by', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'precededBy', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/forkedFrom.ts b/packages/predicates/src/generated/specs/forkedFrom.ts index 57275c4..445f095 100644 --- a/packages/predicates/src/generated/specs/forkedFrom.ts +++ b/packages/predicates/src/generated/specs/forkedFrom.ts @@ -8,4 +8,7 @@ export const forkedFrom = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/founded.ts b/packages/predicates/src/generated/specs/founded.ts index a33d9f3..a83b316 100644 --- a/packages/predicates/src/generated/specs/founded.ts +++ b/packages/predicates/src/generated/specs/founded.ts @@ -9,4 +9,9 @@ export const founded = { category: 'Affiliation/Membership', status: 'proposed', inversePredicate: 'founder', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'founder', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/founder.ts b/packages/predicates/src/generated/specs/founder.ts index a11b33c..9d65a3b 100644 --- a/packages/predicates/src/generated/specs/founder.ts +++ b/packages/predicates/src/generated/specs/founder.ts @@ -10,4 +10,9 @@ export const founder = { category: 'Affiliation/Membership', status: 'proposed', inversePredicate: 'founded', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'founded', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/governedBy.ts b/packages/predicates/src/generated/specs/governedBy.ts index 73cb566..f3d3e59 100644 --- a/packages/predicates/src/generated/specs/governedBy.ts +++ b/packages/predicates/src/generated/specs/governedBy.ts @@ -9,4 +9,7 @@ export const governedBy = { conjugates: false, category: 'Domain-Specific', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/hasCategory.ts b/packages/predicates/src/generated/specs/hasCategory.ts index 177444c..cfbc5eb 100644 --- a/packages/predicates/src/generated/specs/hasCategory.ts +++ b/packages/predicates/src/generated/specs/hasCategory.ts @@ -10,4 +10,7 @@ export const hasCategory = { category: 'Identity/Classification', status: 'enshrined', examples: ['(Uniswap, has category, DeFi)', '(Aave, has category, Lending)'], + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/hasDescription.ts b/packages/predicates/src/generated/specs/hasDescription.ts index 86d286f..720c8a0 100644 --- a/packages/predicates/src/generated/specs/hasDescription.ts +++ b/packages/predicates/src/generated/specs/hasDescription.ts @@ -8,4 +8,8 @@ export const hasDescription = { conjugates: false, category: 'Metadata/Linking', status: 'enshrined', + objectKind: 'literal', + literalType: 'text', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/hasSource.ts b/packages/predicates/src/generated/specs/hasSource.ts index 4c58272..49ab75c 100644 --- a/packages/predicates/src/generated/specs/hasSource.ts +++ b/packages/predicates/src/generated/specs/hasSource.ts @@ -8,4 +8,6 @@ export const hasSource = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/hasTag.ts b/packages/predicates/src/generated/specs/hasTag.ts index 06db794..a67e5f7 100644 --- a/packages/predicates/src/generated/specs/hasTag.ts +++ b/packages/predicates/src/generated/specs/hasTag.ts @@ -10,4 +10,7 @@ export const hasTag = { category: 'Identity/Classification', status: 'enshrined', examples: ['(ETHGlobal, has tag, hackathon)', '(Rust, has tag, systems-programming)'], + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/hasType.ts b/packages/predicates/src/generated/specs/hasType.ts index a6f080d..3a788e1 100644 --- a/packages/predicates/src/generated/specs/hasType.ts +++ b/packages/predicates/src/generated/specs/hasType.ts @@ -10,4 +10,7 @@ export const hasType = { category: 'Identity/Classification', status: 'enshrined', examples: ['(Uniswap, has type, Decentralized Exchange)', '(Ethereum, has type, Blockchain)'], + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/hiringOrganization.ts b/packages/predicates/src/generated/specs/hiringOrganization.ts index 3438b32..2b02731 100644 --- a/packages/predicates/src/generated/specs/hiringOrganization.ts +++ b/packages/predicates/src/generated/specs/hiringOrganization.ts @@ -8,4 +8,7 @@ export const hiringOrganization = { conjugates: false, category: 'Affiliation/Membership', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/imgUrl.ts b/packages/predicates/src/generated/specs/imgUrl.ts index 24899a6..994aa9b 100644 --- a/packages/predicates/src/generated/specs/imgUrl.ts +++ b/packages/predicates/src/generated/specs/imgUrl.ts @@ -9,4 +9,8 @@ export const imgUrl = { conjugates: false, category: 'Metadata/Linking', status: 'enshrined', + objectKind: 'literal', + literalType: 'image', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/implement.ts b/packages/predicates/src/generated/specs/implement.ts index df84422..98c1167 100644 --- a/packages/predicates/src/generated/specs/implement.ts +++ b/packages/predicates/src/generated/specs/implement.ts @@ -10,4 +10,7 @@ export const implement = { thirdPerson: 'implements', category: 'Domain-Specific', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/inAlbum.ts b/packages/predicates/src/generated/specs/inAlbum.ts index 668d3c8..d0f6592 100644 --- a/packages/predicates/src/generated/specs/inAlbum.ts +++ b/packages/predicates/src/generated/specs/inAlbum.ts @@ -8,4 +8,7 @@ export const inAlbum = { conjugates: false, category: 'Curation/Containment', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/inPlaylist.ts b/packages/predicates/src/generated/specs/inPlaylist.ts index 662ca23..eee3e52 100644 --- a/packages/predicates/src/generated/specs/inPlaylist.ts +++ b/packages/predicates/src/generated/specs/inPlaylist.ts @@ -8,4 +8,7 @@ export const inPlaylist = { conjugates: false, category: 'Curation/Containment', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/inspiredBy.ts b/packages/predicates/src/generated/specs/inspiredBy.ts index a1da1d5..c85fd53 100644 --- a/packages/predicates/src/generated/specs/inspiredBy.ts +++ b/packages/predicates/src/generated/specs/inspiredBy.ts @@ -9,4 +9,7 @@ export const inspiredBy = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/investedIn.ts b/packages/predicates/src/generated/specs/investedIn.ts index eebf562..6ab0cb9 100644 --- a/packages/predicates/src/generated/specs/investedIn.ts +++ b/packages/predicates/src/generated/specs/investedIn.ts @@ -8,4 +8,7 @@ export const investedIn = { conjugates: false, category: 'Affiliation/Membership', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/itemReviewed.ts b/packages/predicates/src/generated/specs/itemReviewed.ts index e315ec1..898210e 100644 --- a/packages/predicates/src/generated/specs/itemReviewed.ts +++ b/packages/predicates/src/generated/specs/itemReviewed.ts @@ -8,4 +8,7 @@ export const itemReviewed = { conjugates: false, category: 'Social/Reputation', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/jobLocation.ts b/packages/predicates/src/generated/specs/jobLocation.ts index 0eb115d..7d8216e 100644 --- a/packages/predicates/src/generated/specs/jobLocation.ts +++ b/packages/predicates/src/generated/specs/jobLocation.ts @@ -8,4 +8,7 @@ export const jobLocation = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/learnedFrom.ts b/packages/predicates/src/generated/specs/learnedFrom.ts index 872a162..51c262c 100644 --- a/packages/predicates/src/generated/specs/learnedFrom.ts +++ b/packages/predicates/src/generated/specs/learnedFrom.ts @@ -9,4 +9,7 @@ export const learnedFrom = { conjugates: false, category: 'Knowledge/Expertise', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/like.ts b/packages/predicates/src/generated/specs/like.ts index f66ea4a..5668430 100644 --- a/packages/predicates/src/generated/specs/like.ts +++ b/packages/predicates/src/generated/specs/like.ts @@ -9,4 +9,8 @@ export const like = { thirdPerson: 'likes', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/linkedAccount.ts b/packages/predicates/src/generated/specs/linkedAccount.ts index 9a62d86..7e93d54 100644 --- a/packages/predicates/src/generated/specs/linkedAccount.ts +++ b/packages/predicates/src/generated/specs/linkedAccount.ts @@ -8,4 +8,7 @@ export const linkedAccount = { conjugates: false, category: 'Metadata/Linking', status: 'enshrined', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/listedIn.ts b/packages/predicates/src/generated/specs/listedIn.ts index 10c4be3..f452153 100644 --- a/packages/predicates/src/generated/specs/listedIn.ts +++ b/packages/predicates/src/generated/specs/listedIn.ts @@ -11,4 +11,8 @@ export const listedIn = { status: 'enshrined', isHierarchical: true, inversePredicate: 'contain', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + inverse: 'contain', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/listedOn.ts b/packages/predicates/src/generated/specs/listedOn.ts index b26e041..1a67815 100644 --- a/packages/predicates/src/generated/specs/listedOn.ts +++ b/packages/predicates/src/generated/specs/listedOn.ts @@ -9,4 +9,7 @@ export const listedOn = { conjugates: false, category: 'Economic/Market', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/locatedIn.ts b/packages/predicates/src/generated/specs/locatedIn.ts index 1df097d..27f83a5 100644 --- a/packages/predicates/src/generated/specs/locatedIn.ts +++ b/packages/predicates/src/generated/specs/locatedIn.ts @@ -11,4 +11,7 @@ export const locatedIn = { status: 'proposed', isTransitive: true, isHierarchical: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/manufacturer.ts b/packages/predicates/src/generated/specs/manufacturer.ts index c9e715c..cc21253 100644 --- a/packages/predicates/src/generated/specs/manufacturer.ts +++ b/packages/predicates/src/generated/specs/manufacturer.ts @@ -8,4 +8,7 @@ export const manufacturer = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/memberOf.ts b/packages/predicates/src/generated/specs/memberOf.ts index 726a6fe..2767518 100644 --- a/packages/predicates/src/generated/specs/memberOf.ts +++ b/packages/predicates/src/generated/specs/memberOf.ts @@ -9,4 +9,8 @@ export const memberOf = { category: 'Affiliation/Membership', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/mentorOf.ts b/packages/predicates/src/generated/specs/mentorOf.ts index 0130f6d..45eef5d 100644 --- a/packages/predicates/src/generated/specs/mentorOf.ts +++ b/packages/predicates/src/generated/specs/mentorOf.ts @@ -9,4 +9,8 @@ export const mentorOf = { category: 'Knowledge/Expertise', status: 'proposed', inversePredicate: 'student of', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + inverse: 'studentOf', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/musicBy.ts b/packages/predicates/src/generated/specs/musicBy.ts index d60f235..366777c 100644 --- a/packages/predicates/src/generated/specs/musicBy.ts +++ b/packages/predicates/src/generated/specs/musicBy.ts @@ -8,4 +8,7 @@ export const musicBy = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/musicGroupMember.ts b/packages/predicates/src/generated/specs/musicGroupMember.ts index 9bd8739..000bc37 100644 --- a/packages/predicates/src/generated/specs/musicGroupMember.ts +++ b/packages/predicates/src/generated/specs/musicGroupMember.ts @@ -9,4 +9,7 @@ export const musicGroupMember = { category: 'Affiliation/Membership', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/neutralOn.ts b/packages/predicates/src/generated/specs/neutralOn.ts index 8ab0c2b..8eff021 100644 --- a/packages/predicates/src/generated/specs/neutralOn.ts +++ b/packages/predicates/src/generated/specs/neutralOn.ts @@ -9,4 +9,8 @@ export const neutralOn = { conjugates: false, category: 'Sentiment/Opinion', status: 'proposed', + objectKind: 'entity', + polarity: 'neutral', + temporalNature: 'state', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/oppose.ts b/packages/predicates/src/generated/specs/oppose.ts index 040c5f4..e5e5dc4 100644 --- a/packages/predicates/src/generated/specs/oppose.ts +++ b/packages/predicates/src/generated/specs/oppose.ts @@ -10,4 +10,9 @@ export const oppose = { thirdPerson: 'opposes', category: 'Sentiment/Opinion', status: 'proposed', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['support'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/organizer.ts b/packages/predicates/src/generated/specs/organizer.ts index a69def8..37d5bb9 100644 --- a/packages/predicates/src/generated/specs/organizer.ts +++ b/packages/predicates/src/generated/specs/organizer.ts @@ -8,4 +8,7 @@ export const organizer = { conjugates: false, category: 'Affiliation/Membership', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/outperform.ts b/packages/predicates/src/generated/specs/outperform.ts index 1c551eb..e03953a 100644 --- a/packages/predicates/src/generated/specs/outperform.ts +++ b/packages/predicates/src/generated/specs/outperform.ts @@ -10,4 +10,7 @@ export const outperform = { thirdPerson: 'outperforms', category: 'Comparison/Ranking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/parentItem.ts b/packages/predicates/src/generated/specs/parentItem.ts index 3e7f2db..2144759 100644 --- a/packages/predicates/src/generated/specs/parentItem.ts +++ b/packages/predicates/src/generated/specs/parentItem.ts @@ -9,4 +9,7 @@ export const parentItem = { category: 'Curation/Containment', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/parentOrganization.ts b/packages/predicates/src/generated/specs/parentOrganization.ts index d8661e5..b117984 100644 --- a/packages/predicates/src/generated/specs/parentOrganization.ts +++ b/packages/predicates/src/generated/specs/parentOrganization.ts @@ -11,4 +11,8 @@ export const parentOrganization = { isTransitive: true, isHierarchical: true, inversePredicate: 'sub organization', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + inverse: 'subOrganization', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/partOfSeries.ts b/packages/predicates/src/generated/specs/partOfSeries.ts index d8ab892..2505a9b 100644 --- a/packages/predicates/src/generated/specs/partOfSeries.ts +++ b/packages/predicates/src/generated/specs/partOfSeries.ts @@ -8,4 +8,7 @@ export const partOfSeries = { conjugates: false, category: 'Curation/Containment', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/partnerOf.ts b/packages/predicates/src/generated/specs/partnerOf.ts index ee80772..9d94844 100644 --- a/packages/predicates/src/generated/specs/partnerOf.ts +++ b/packages/predicates/src/generated/specs/partnerOf.ts @@ -9,4 +9,8 @@ export const partnerOf = { category: 'Affiliation/Membership', status: 'proposed', isSymmetric: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + specializes: ['affiliatedWith'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/peggedTo.ts b/packages/predicates/src/generated/specs/peggedTo.ts index 50c4ea5..5c1b426 100644 --- a/packages/predicates/src/generated/specs/peggedTo.ts +++ b/packages/predicates/src/generated/specs/peggedTo.ts @@ -9,4 +9,7 @@ export const peggedTo = { conjugates: false, category: 'Economic/Market', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/performer.ts b/packages/predicates/src/generated/specs/performer.ts index 34e48d3..f6fa898 100644 --- a/packages/predicates/src/generated/specs/performer.ts +++ b/packages/predicates/src/generated/specs/performer.ts @@ -8,4 +8,7 @@ export const performer = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/photo.ts b/packages/predicates/src/generated/specs/photo.ts index 91b080f..3fb182d 100644 --- a/packages/predicates/src/generated/specs/photo.ts +++ b/packages/predicates/src/generated/specs/photo.ts @@ -8,4 +8,8 @@ export const photo = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'literal', + literalType: 'image', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/pinnedIn.ts b/packages/predicates/src/generated/specs/pinnedIn.ts index e2a2734..7a46633 100644 --- a/packages/predicates/src/generated/specs/pinnedIn.ts +++ b/packages/predicates/src/generated/specs/pinnedIn.ts @@ -10,4 +10,7 @@ export const pinnedIn = { category: 'Curation/Containment', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/precededBy.ts b/packages/predicates/src/generated/specs/precededBy.ts index 871c8f4..0eee53f 100644 --- a/packages/predicates/src/generated/specs/precededBy.ts +++ b/packages/predicates/src/generated/specs/precededBy.ts @@ -11,4 +11,8 @@ export const precededBy = { status: 'proposed', isTransitive: true, inversePredicate: 'followed by', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'followedBy', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/predecessorOf.ts b/packages/predicates/src/generated/specs/predecessorOf.ts index fc2dc69..9ee982c 100644 --- a/packages/predicates/src/generated/specs/predecessorOf.ts +++ b/packages/predicates/src/generated/specs/predecessorOf.ts @@ -9,4 +9,8 @@ export const predecessorOf = { category: 'Comparison/Ranking', status: 'proposed', inversePredicate: 'successor of', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'successorOf', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/pricedIn.ts b/packages/predicates/src/generated/specs/pricedIn.ts index 5fc3f31..fe7374e 100644 --- a/packages/predicates/src/generated/specs/pricedIn.ts +++ b/packages/predicates/src/generated/specs/pricedIn.ts @@ -9,4 +9,7 @@ export const pricedIn = { conjugates: false, category: 'Domain-Specific', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/primaryImageOfPage.ts b/packages/predicates/src/generated/specs/primaryImageOfPage.ts index ca9ef2f..90dfed1 100644 --- a/packages/predicates/src/generated/specs/primaryImageOfPage.ts +++ b/packages/predicates/src/generated/specs/primaryImageOfPage.ts @@ -8,4 +8,8 @@ export const primaryImageOfPage = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'literal', + literalType: 'image', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/productionCompany.ts b/packages/predicates/src/generated/specs/productionCompany.ts index 6204f3b..dfadd30 100644 --- a/packages/predicates/src/generated/specs/productionCompany.ts +++ b/packages/predicates/src/generated/specs/productionCompany.ts @@ -8,4 +8,7 @@ export const productionCompany = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/proposed.ts b/packages/predicates/src/generated/specs/proposed.ts index edf4998..74f3389 100644 --- a/packages/predicates/src/generated/specs/proposed.ts +++ b/packages/predicates/src/generated/specs/proposed.ts @@ -9,4 +9,7 @@ export const proposed = { conjugates: false, category: 'Governance/Policy', status: 'proposed', + objectKind: 'entity', + temporalNature: 'event', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/provider.ts b/packages/predicates/src/generated/specs/provider.ts index 29d9679..5e786b8 100644 --- a/packages/predicates/src/generated/specs/provider.ts +++ b/packages/predicates/src/generated/specs/provider.ts @@ -9,4 +9,7 @@ export const provider = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/publishedAt.ts b/packages/predicates/src/generated/specs/publishedAt.ts index 242ba2c..93fe2e6 100644 --- a/packages/predicates/src/generated/specs/publishedAt.ts +++ b/packages/predicates/src/generated/specs/publishedAt.ts @@ -9,4 +9,7 @@ export const publishedAt = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/publisher.ts b/packages/predicates/src/generated/specs/publisher.ts index 4e45509..35afe2d 100644 --- a/packages/predicates/src/generated/specs/publisher.ts +++ b/packages/predicates/src/generated/specs/publisher.ts @@ -9,4 +9,7 @@ export const publisher = { conjugates: false, category: 'Authorship/Contribution', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/rankedAbove.ts b/packages/predicates/src/generated/specs/rankedAbove.ts index 97206a8..fba8584 100644 --- a/packages/predicates/src/generated/specs/rankedAbove.ts +++ b/packages/predicates/src/generated/specs/rankedAbove.ts @@ -9,4 +9,7 @@ export const rankedAbove = { conjugates: false, category: 'Curation/Containment', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/recommend.ts b/packages/predicates/src/generated/specs/recommend.ts index 55db37b..94246d9 100644 --- a/packages/predicates/src/generated/specs/recommend.ts +++ b/packages/predicates/src/generated/specs/recommend.ts @@ -10,4 +10,8 @@ export const recommend = { thirdPerson: 'recommends', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/reference.ts b/packages/predicates/src/generated/specs/reference.ts index a503b0a..f68554d 100644 --- a/packages/predicates/src/generated/specs/reference.ts +++ b/packages/predicates/src/generated/specs/reference.ts @@ -11,4 +11,8 @@ export const reference = { category: 'Provenance/Evidence', status: 'proposed', inversePredicate: 'cited by', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'citedBy', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/regulatedBy.ts b/packages/predicates/src/generated/specs/regulatedBy.ts index bf7d632..83630ac 100644 --- a/packages/predicates/src/generated/specs/regulatedBy.ts +++ b/packages/predicates/src/generated/specs/regulatedBy.ts @@ -9,4 +9,7 @@ export const regulatedBy = { conjugates: false, category: 'Governance/Policy', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/replacedBy.ts b/packages/predicates/src/generated/specs/replacedBy.ts index 593bd28..917fcad 100644 --- a/packages/predicates/src/generated/specs/replacedBy.ts +++ b/packages/predicates/src/generated/specs/replacedBy.ts @@ -9,4 +9,7 @@ export const replacedBy = { conjugates: false, category: 'Temporal/Lifecycle', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/reported.ts b/packages/predicates/src/generated/specs/reported.ts index 8ccd65e..0c123a4 100644 --- a/packages/predicates/src/generated/specs/reported.ts +++ b/packages/predicates/src/generated/specs/reported.ts @@ -8,4 +8,8 @@ export const reported = { conjugates: false, category: 'Social/Reputation', status: 'proposed', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'event', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/reviewed.ts b/packages/predicates/src/generated/specs/reviewed.ts index 3158f17..d0c7594 100644 --- a/packages/predicates/src/generated/specs/reviewed.ts +++ b/packages/predicates/src/generated/specs/reviewed.ts @@ -8,4 +8,8 @@ export const reviewed = { conjugates: false, category: 'Social/Reputation', status: 'proposed', + objectKind: 'entity', + temporalNature: 'event', + claimType: 'evaluative', + inverse: 'reviewedBy', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/reviewedBy.ts b/packages/predicates/src/generated/specs/reviewedBy.ts index 225180c..f148e71 100644 --- a/packages/predicates/src/generated/specs/reviewedBy.ts +++ b/packages/predicates/src/generated/specs/reviewedBy.ts @@ -8,4 +8,8 @@ export const reviewedBy = { conjugates: false, category: 'Social/Reputation', status: 'proposed', + objectKind: 'entity', + temporalNature: 'event', + claimType: 'evaluative', + inverse: 'reviewed', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/reward.ts b/packages/predicates/src/generated/specs/reward.ts index 60516fd..6ea10de 100644 --- a/packages/predicates/src/generated/specs/reward.ts +++ b/packages/predicates/src/generated/specs/reward.ts @@ -10,4 +10,8 @@ export const reward = { thirdPerson: 'rewards', category: 'Economic/Market', status: 'proposed', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/sameAs.ts b/packages/predicates/src/generated/specs/sameAs.ts index b219e7e..75a1fe9 100644 --- a/packages/predicates/src/generated/specs/sameAs.ts +++ b/packages/predicates/src/generated/specs/sameAs.ts @@ -13,4 +13,7 @@ export const sameAs = { isTransitive: true, isSymmetric: true, inversePredicate: 'same as', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/skepticalOf.ts b/packages/predicates/src/generated/specs/skepticalOf.ts index 5033fff..9270645 100644 --- a/packages/predicates/src/generated/specs/skepticalOf.ts +++ b/packages/predicates/src/generated/specs/skepticalOf.ts @@ -9,4 +9,8 @@ export const skepticalOf = { conjugates: false, category: 'Sentiment/Opinion', status: 'proposed', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'state', + claimType: 'evaluative', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/softwareAddOn.ts b/packages/predicates/src/generated/specs/softwareAddOn.ts index 3c9e5d8..7612290 100644 --- a/packages/predicates/src/generated/specs/softwareAddOn.ts +++ b/packages/predicates/src/generated/specs/softwareAddOn.ts @@ -9,4 +9,7 @@ export const softwareAddOn = { conjugates: false, category: 'Domain-Specific', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/speak.ts b/packages/predicates/src/generated/specs/speak.ts index 5fae480..25d264c 100644 --- a/packages/predicates/src/generated/specs/speak.ts +++ b/packages/predicates/src/generated/specs/speak.ts @@ -10,4 +10,7 @@ export const speak = { thirdPerson: 'speaks', category: 'Knowledge/Expertise', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/sponsoredBy.ts b/packages/predicates/src/generated/specs/sponsoredBy.ts index 7df8dd0..dc05f7b 100644 --- a/packages/predicates/src/generated/specs/sponsoredBy.ts +++ b/packages/predicates/src/generated/specs/sponsoredBy.ts @@ -9,4 +9,7 @@ export const sponsoredBy = { conjugates: false, category: 'Economic/Market', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/stakedIn.ts b/packages/predicates/src/generated/specs/stakedIn.ts index 346d450..a389ab4 100644 --- a/packages/predicates/src/generated/specs/stakedIn.ts +++ b/packages/predicates/src/generated/specs/stakedIn.ts @@ -9,4 +9,7 @@ export const stakedIn = { conjugates: false, category: 'Economic/Market', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/studentOf.ts b/packages/predicates/src/generated/specs/studentOf.ts index 7fa3e5f..8f9f2df 100644 --- a/packages/predicates/src/generated/specs/studentOf.ts +++ b/packages/predicates/src/generated/specs/studentOf.ts @@ -10,4 +10,9 @@ export const studentOf = { category: 'Knowledge/Expertise', status: 'proposed', inversePredicate: 'mentor of', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + inverse: 'mentorOf', + specializes: ['learnedFrom'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/studied.ts b/packages/predicates/src/generated/specs/studied.ts index e3f236a..1903201 100644 --- a/packages/predicates/src/generated/specs/studied.ts +++ b/packages/predicates/src/generated/specs/studied.ts @@ -9,4 +9,7 @@ export const studied = { conjugates: false, category: 'Knowledge/Expertise', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/subEvent.ts b/packages/predicates/src/generated/specs/subEvent.ts index e4852e6..4ed541a 100644 --- a/packages/predicates/src/generated/specs/subEvent.ts +++ b/packages/predicates/src/generated/specs/subEvent.ts @@ -11,4 +11,8 @@ export const subEvent = { isTransitive: true, isHierarchical: true, inversePredicate: 'super event', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'superEvent', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/subOrganization.ts b/packages/predicates/src/generated/specs/subOrganization.ts index dddd838..9b2c10e 100644 --- a/packages/predicates/src/generated/specs/subOrganization.ts +++ b/packages/predicates/src/generated/specs/subOrganization.ts @@ -11,4 +11,8 @@ export const subOrganization = { isTransitive: true, isHierarchical: true, inversePredicate: 'parent organization', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', + inverse: 'parentOrganization', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/successorOf.ts b/packages/predicates/src/generated/specs/successorOf.ts index 9d2cb4e..83c4382 100644 --- a/packages/predicates/src/generated/specs/successorOf.ts +++ b/packages/predicates/src/generated/specs/successorOf.ts @@ -9,4 +9,8 @@ export const successorOf = { category: 'Comparison/Ranking', status: 'proposed', inversePredicate: 'predecessor of', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'predecessorOf', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/superEvent.ts b/packages/predicates/src/generated/specs/superEvent.ts index 12c1db6..f066743 100644 --- a/packages/predicates/src/generated/specs/superEvent.ts +++ b/packages/predicates/src/generated/specs/superEvent.ts @@ -11,4 +11,8 @@ export const superEvent = { isTransitive: true, isHierarchical: true, inversePredicate: 'sub event', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', + inverse: 'subEvent', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/supersede.ts b/packages/predicates/src/generated/specs/supersede.ts index 364daf0..842e3ac 100644 --- a/packages/predicates/src/generated/specs/supersede.ts +++ b/packages/predicates/src/generated/specs/supersede.ts @@ -9,4 +9,7 @@ export const supersede = { conjugates: false, category: 'Comparison/Ranking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/support.ts b/packages/predicates/src/generated/specs/support.ts index 55f2a04..4d5ae99 100644 --- a/packages/predicates/src/generated/specs/support.ts +++ b/packages/predicates/src/generated/specs/support.ts @@ -10,4 +10,9 @@ export const support = { thirdPerson: 'supports', category: 'Sentiment/Opinion', status: 'proposed', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['oppose'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/targetProduct.ts b/packages/predicates/src/generated/specs/targetProduct.ts index fb0b2c0..ea1a0dc 100644 --- a/packages/predicates/src/generated/specs/targetProduct.ts +++ b/packages/predicates/src/generated/specs/targetProduct.ts @@ -9,4 +9,7 @@ export const targetProduct = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/teach.ts b/packages/predicates/src/generated/specs/teach.ts index 4e9182f..47cd1ea 100644 --- a/packages/predicates/src/generated/specs/teach.ts +++ b/packages/predicates/src/generated/specs/teach.ts @@ -9,4 +9,7 @@ export const teach = { thirdPerson: 'teaches', category: 'Knowledge/Expertise', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/track.ts b/packages/predicates/src/generated/specs/track.ts index c53afb5..201bdcc 100644 --- a/packages/predicates/src/generated/specs/track.ts +++ b/packages/predicates/src/generated/specs/track.ts @@ -10,4 +10,7 @@ export const track = { category: 'Curation/Containment', status: 'proposed', isHierarchical: true, + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/trailer.ts b/packages/predicates/src/generated/specs/trailer.ts index e6bcdc4..b66b054 100644 --- a/packages/predicates/src/generated/specs/trailer.ts +++ b/packages/predicates/src/generated/specs/trailer.ts @@ -8,4 +8,7 @@ export const trailer = { conjugates: false, category: 'Metadata/Linking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/triggered.ts b/packages/predicates/src/generated/specs/triggered.ts index a1eac03..7867fa7 100644 --- a/packages/predicates/src/generated/specs/triggered.ts +++ b/packages/predicates/src/generated/specs/triggered.ts @@ -9,4 +9,7 @@ export const triggered = { conjugates: false, category: 'Temporal/Lifecycle', status: 'proposed', + objectKind: 'entity', + temporalNature: 'event', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/trust.ts b/packages/predicates/src/generated/specs/trust.ts index cfb4413..595d65b 100644 --- a/packages/predicates/src/generated/specs/trust.ts +++ b/packages/predicates/src/generated/specs/trust.ts @@ -10,4 +10,9 @@ export const trust = { thirdPerson: 'trusts', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', + contradicts: ['distrust'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/url.ts b/packages/predicates/src/generated/specs/url.ts index f322e9c..40fec9a 100644 --- a/packages/predicates/src/generated/specs/url.ts +++ b/packages/predicates/src/generated/specs/url.ts @@ -8,4 +8,8 @@ export const url = { conjugates: false, category: 'Metadata/Linking', status: 'enshrined', + objectKind: 'literal', + literalType: 'url', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/use.ts b/packages/predicates/src/generated/specs/use.ts index bb6b8df..d69a75d 100644 --- a/packages/predicates/src/generated/specs/use.ts +++ b/packages/predicates/src/generated/specs/use.ts @@ -10,4 +10,7 @@ export const use = { thirdPerson: 'uses', category: 'Domain-Specific', status: 'proposed', + objectKind: 'entity', + temporalNature: 'state', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/verifiedBy.ts b/packages/predicates/src/generated/specs/verifiedBy.ts index 6bcfe69..302ecaf 100644 --- a/packages/predicates/src/generated/specs/verifiedBy.ts +++ b/packages/predicates/src/generated/specs/verifiedBy.ts @@ -9,4 +9,7 @@ export const verifiedBy = { conjugates: false, category: 'Provenance/Evidence', status: 'proposed', + objectKind: 'claim', + temporalNature: 'permanent', + claimType: 'factual', } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/votedAgainst.ts b/packages/predicates/src/generated/specs/votedAgainst.ts index 85f8998..fbcbca9 100644 --- a/packages/predicates/src/generated/specs/votedAgainst.ts +++ b/packages/predicates/src/generated/specs/votedAgainst.ts @@ -8,4 +8,9 @@ export const votedAgainst = { conjugates: false, category: 'Governance/Policy', status: 'proposed', + objectKind: 'entity', + polarity: 'negative', + temporalNature: 'event', + claimType: 'factual', + contradicts: ['votedFor'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/votedFor.ts b/packages/predicates/src/generated/specs/votedFor.ts index e25e979..48d77e8 100644 --- a/packages/predicates/src/generated/specs/votedFor.ts +++ b/packages/predicates/src/generated/specs/votedFor.ts @@ -8,4 +8,9 @@ export const votedFor = { conjugates: false, category: 'Governance/Policy', status: 'proposed', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'event', + claimType: 'factual', + contradicts: ['votedAgainst'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/vouchFor.ts b/packages/predicates/src/generated/specs/vouchFor.ts index 328b11a..6b0143d 100644 --- a/packages/predicates/src/generated/specs/vouchFor.ts +++ b/packages/predicates/src/generated/specs/vouchFor.ts @@ -10,4 +10,9 @@ export const vouchFor = { thirdPerson: 'vouches for', category: 'Social/Reputation', status: 'enshrined', + objectKind: 'entity', + polarity: 'positive', + temporalNature: 'state', + claimType: 'evaluative', + specializes: ['support'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/generated/specs/worseThan.ts b/packages/predicates/src/generated/specs/worseThan.ts index 1b367a1..7e7b470 100644 --- a/packages/predicates/src/generated/specs/worseThan.ts +++ b/packages/predicates/src/generated/specs/worseThan.ts @@ -9,4 +9,8 @@ export const worseThan = { conjugates: false, category: 'Comparison/Ranking', status: 'proposed', + objectKind: 'entity', + temporalNature: 'permanent', + claimType: 'evaluative', + contradicts: ['betterThan'], } as const satisfies PredicateSpec; diff --git a/packages/predicates/src/index.ts b/packages/predicates/src/index.ts index 1facd8b..2f9941e 100644 --- a/packages/predicates/src/index.ts +++ b/packages/predicates/src/index.ts @@ -7,3 +7,4 @@ export * from './ipfs'; export * from './launch'; export * from './predicates'; export * from './types'; +export * from './validate'; diff --git a/packages/predicates/src/ipfs.ts b/packages/predicates/src/ipfs.ts index a3daa20..70372b9 100644 --- a/packages/predicates/src/ipfs.ts +++ b/packages/predicates/src/ipfs.ts @@ -68,6 +68,27 @@ export function buildPredicateIpfsDocument( }); } + // Machine-readable semantics (serialized per predicate-spec-decisions.md §5.3). Only emitted when set. + const optionalProperties: Array< + [PredicateIpfsDocument['additionalProperty'][number]['name'], unknown] + > = [ + ['objectKind', options.objectKind], + ['literalType', options.literalType], + ['polarity', options.polarity], + ['temporalNature', options.temporalNature], + ['claimType', options.claimType], + ['inverse', options.inverse], + ['specializes', options.specializes], + ['contradicts', options.contradicts], + ['supersededBy', options.supersededBy], + ]; + + for (const [name, value] of optionalProperties) { + if (value === undefined) continue; + if (Array.isArray(value) && value.length === 0) continue; + additionalProperty.push({ '@type': 'PropertyValue', name, value }); + } + const document: PredicateIpfsDocument = { '@context': 'https://schema.org/', '@type': 'DefinedTerm', diff --git a/packages/predicates/src/record.ts b/packages/predicates/src/record.ts index 7eb7db2..7c476f1 100644 --- a/packages/predicates/src/record.ts +++ b/packages/predicates/src/record.ts @@ -1,8 +1,55 @@ import type { PredicateRecord, PredicateSpec } from './types.js'; +/** + * Per-spec consistency gate. Checks the rules that need only a single spec (see + * `.planning/predicate-spec-decisions.md` §5.1). Cross-referential rules that need the whole set + * (inverse mirroring, contradicts symmetry, specializes acyclicity, key existence) live in + * `validate.ts` and run over `PREDICATE_SPECS`. + */ +function assertSpecConsistency(spec: PredicateSpec): void { + const where = `predicate "${spec.key}"`; + + // Rule 1 — isSymmetric ⟹ no distinct `inverse` (a symmetric predicate is its own inverse). + if (spec.isSymmetric && spec.inverse && spec.inverse !== spec.key) { + throw new Error( + `${where}: isSymmetric predicates are their own inverse; remove inverse: "${spec.inverse}".` + ); + } + + // Rule 4 — contradicts is irreflexive. + if (spec.contradicts?.includes(spec.key)) { + throw new Error(`${where}: contradicts must not include the predicate itself.`); + } + + // Rule 10 — sentiment is never transitive (Guha 2004). polarity ∧ isTransitive is incoherent. + if (spec.polarity !== undefined && spec.isTransitive) { + throw new Error( + `${where}: a predicate with polarity ("${spec.polarity}") cannot be isTransitive — sentiment does not propagate transitively.` + ); + } + + // Rule 12 — literalType only means anything for a literal object. + if (spec.literalType !== undefined && spec.objectKind !== 'literal') { + throw new Error( + `${where}: literalType ("${spec.literalType}") requires objectKind: 'literal' (got ${ + spec.objectKind ?? 'undefined' + }).` + ); + } + + // Rule 14 (partial) — supersededBy only makes sense on a deprecated predicate. + if (spec.supersededBy !== undefined && spec.status !== 'deprecated') { + throw new Error( + `${where}: supersededBy is only valid when status === 'deprecated' (got '${spec.status}').` + ); + } +} + export function definePredicateRecord( spec: TSpec ): PredicateRecord { + assertSpecConsistency(spec); + return { key: spec.key, name: spec.name, @@ -17,5 +64,14 @@ export function definePredicateRecord( ...(spec.thirdPerson ? { thirdPerson: spec.thirdPerson } : {}), ...(spec.examples ? { examples: spec.examples } : {}), ...(spec.inversePredicate ? { inversePredicate: spec.inversePredicate } : {}), + ...(spec.objectKind ? { objectKind: spec.objectKind } : {}), + ...(spec.literalType ? { literalType: spec.literalType } : {}), + ...(spec.polarity ? { polarity: spec.polarity } : {}), + ...(spec.temporalNature ? { temporalNature: spec.temporalNature } : {}), + ...(spec.claimType ? { claimType: spec.claimType } : {}), + ...(spec.inverse ? { inverse: spec.inverse } : {}), + ...(spec.specializes ? { specializes: spec.specializes } : {}), + ...(spec.contradicts ? { contradicts: spec.contradicts } : {}), + ...(spec.supersededBy ? { supersededBy: spec.supersededBy } : {}), }; } diff --git a/packages/predicates/src/types.ts b/packages/predicates/src/types.ts index 609eeb8..296c26d 100644 --- a/packages/predicates/src/types.ts +++ b/packages/predicates/src/types.ts @@ -3,6 +3,29 @@ export type PredicateStorageStrategy = 'inline' | 'ipfs'; export type PredicateForm = 'base' | 'thirdPerson' | 'pastParticiple' | 'displayName'; export type TextDirection = 'ltr' | 'rtl'; +/** + * Machine-readable relationship semantics added per `.planning/predicate-spec-decisions.md`. + * All optional and additive — a predicate omitting them behaves exactly as before. + */ + +/** What the object of an edge is, so a consumer can render/traverse it without per-predicate code. */ +export type PredicateObjectKind = 'entity' | 'claim' | 'literal'; +/** Refines `objectKind: 'literal'` into a concrete datatype (RDF/XSD-aligned). Only meaningful when objectKind === 'literal'. */ +export type PredicateLiteralType = 'url' | 'image' | 'date' | 'number' | 'text'; +/** Sign of the edge from subject toward object; signed aggregation accrues to the object. */ +export type PredicatePolarity = 'positive' | 'negative' | 'neutral'; +/** Freshness/lifecycle model — which edges silently decay, which are write-once. */ +export type PredicateTemporalNature = 'permanent' | 'state' | 'event'; +/** Fact vs. opinion, for market design and UI semantics. */ +export type PredicateClaimType = 'factual' | 'evaluative'; +/** + * A reference to another predicate by its `key`. Existence and cross-referential consistency + * (inverse mirroring, contradicts symmetry, specializes acyclicity) are validated over the full + * set in `validate.ts`; the raw type is `string` to avoid a circular dependency on the derived + * `PredicateKey` union (which is computed from the spec array). + */ +export type PredicateKeyRef = string; + export type PredicateCategory = | 'Identity/Classification' | 'Social/Reputation' @@ -31,6 +54,16 @@ export interface PredicateDefinition { isSymmetric: boolean; isHierarchical: boolean; inversePredicate?: string; + // machine-readable semantics (see PredicateSpec) + objectKind?: PredicateObjectKind; + literalType?: PredicateLiteralType; + polarity?: PredicatePolarity; + temporalNature?: PredicateTemporalNature; + claimType?: PredicateClaimType; + inverse?: PredicateKeyRef; + specializes?: readonly PredicateKeyRef[]; + contradicts?: readonly PredicateKeyRef[]; + supersededBy?: PredicateKeyRef; } export type PredicateStatus = 'enshrined' | 'proposed' | 'deprecated'; @@ -45,10 +78,32 @@ export interface PredicateSpec { category: PredicateCategory; status: PredicateStatus; examples?: readonly string[]; + + // algebraic (OWL-aligned, flat, is* convention) isTransitive?: boolean; isSymmetric?: boolean; + /** @deprecated Legacy display-name of the inverse. Prefer the typed-key `inverse`; kept for back-compat. */ isHierarchical?: boolean; + /** @deprecated Display-name of the inverse predicate. Prefer the typed-key `inverse`; kept for back-compat. */ inversePredicate?: string; + + // rendering contract (frontend reads these directly) + objectKind?: PredicateObjectKind; + /** Only meaningful when `objectKind === 'literal'` (validated). */ + literalType?: PredicateLiteralType; + polarity?: PredicatePolarity; + temporalNature?: PredicateTemporalNature; + claimType?: PredicateClaimType; + + // inter-predicate (typed key references — validated over the full set in validate.ts) + /** The reverse-direction predicate's `key` (different predicate). Omit for symmetric predicates (inverse = self). */ + inverse?: PredicateKeyRef; + /** Parent predicate `key`(s) this one is a sub-property of. A DAG: usually one, occasionally two. */ + specializes?: readonly PredicateKeyRef[]; + /** Predicate `key`(s) that are pair-level disjoint with this one (declared symmetrically). */ + contradicts?: readonly PredicateKeyRef[]; + /** Successor predicate `key`; only valid when `status === 'deprecated'`. */ + supersededBy?: PredicateKeyRef; } export type PredicateRecord = PredicateDefinition & { @@ -99,6 +154,15 @@ export interface PredicateIpfsOptions { isSymmetric?: boolean; isHierarchical?: boolean; inversePredicate?: string; + objectKind?: PredicateObjectKind; + literalType?: PredicateLiteralType; + polarity?: PredicatePolarity; + temporalNature?: PredicateTemporalNature; + claimType?: PredicateClaimType; + inverse?: PredicateKeyRef; + specializes?: readonly PredicateKeyRef[]; + contradicts?: readonly PredicateKeyRef[]; + supersededBy?: PredicateKeyRef; } export type PredicateIpfsPropertyName = @@ -108,7 +172,16 @@ export type PredicateIpfsPropertyName = | 'isTransitive' | 'isSymmetric' | 'isHierarchical' - | 'inversePredicate'; + | 'inversePredicate' + | 'objectKind' + | 'literalType' + | 'polarity' + | 'temporalNature' + | 'claimType' + | 'inverse' + | 'specializes' + | 'contradicts' + | 'supersededBy'; export interface PredicateIpfsDocument extends PredicateAtomDocument { inDefinedTermSet: string; diff --git a/packages/predicates/src/validate.test.ts b/packages/predicates/src/validate.test.ts new file mode 100644 index 0000000..ded31eb --- /dev/null +++ b/packages/predicates/src/validate.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { PREDICATE_SPECS } from './generated/index'; +import { validatePredicateSet } from './validate'; + +describe('predicate set consistency gate', () => { + const result = validatePredicateSet(PREDICATE_SPECS); + + it('has zero hard errors across the full registry', () => { + expect(result.errors).toEqual([]); + }); + + it('declares inverse pairs symmetrically with mirrored transitivity', () => { + const byKey = new Map(PREDICATE_SPECS.map((s) => [s.key, s])); + for (const spec of PREDICATE_SPECS) { + if (!spec.inverse) continue; + const other = byKey.get(spec.inverse); + expect(other, `${spec.key}.inverse -> ${spec.inverse}`).toBeDefined(); + expect(other?.inverse).toBe(spec.key); + expect(Boolean(other?.isTransitive)).toBe(Boolean(spec.isTransitive)); + } + }); + + it('declares contradicts symmetrically and never on itself', () => { + const byKey = new Map(PREDICATE_SPECS.map((s) => [s.key, s])); + for (const spec of PREDICATE_SPECS) { + for (const ref of spec.contradicts ?? []) { + expect(ref).not.toBe(spec.key); + expect(byKey.get(ref)?.contradicts ?? []).toContain(spec.key); + } + } + }); + + it('never marks a polarity-bearing predicate transitive (Guha 2004)', () => { + for (const spec of PREDICATE_SPECS) { + if (spec.polarity !== undefined) expect(spec.isTransitive ?? false).toBe(false); + } + }); + + it('only attaches literalType to literal objects', () => { + for (const spec of PREDICATE_SPECS) { + if (spec.literalType !== undefined) expect(spec.objectKind).toBe('literal'); + } + }); + + it('closes contradictions down the specializes hierarchy (rule 7)', () => { + // endorse ⊑ support, support ⊥ oppose ⟹ endorse ⊥ oppose (derived, not authored). + expect(result.contradictionClosure.endorse).toContain('oppose'); + expect(result.contradictionClosure.vouchFor).toContain('oppose'); + }); +}); diff --git a/packages/predicates/src/validate.ts b/packages/predicates/src/validate.ts new file mode 100644 index 0000000..4836a6a --- /dev/null +++ b/packages/predicates/src/validate.ts @@ -0,0 +1,170 @@ +import type { PredicateSpec } from './types.js'; + +/** + * Cross-referential consistency gate for the predicate set (see + * `.planning/predicate-spec-decisions.md` §5.1). These rules need the whole set — key existence, + * inverse mirroring, contradicts symmetry, specializes acyclicity, contradiction-vs-hierarchy + * coherence. Per-spec rules (symmetry/inverse conflict, polarity∧transitive, literalType, + * supersededBy status) are enforced in `definePredicateRecord`. + * + * `errors` are hard violations (should fail the build/tests). `warnings` are the lint-level rules + * (near-universal but with conceivable exceptions) that are surfaced, not enforced. + */ +export interface PredicateSetValidation { + errors: string[]; + warnings: string[]; + /** Contradiction set closed down the specializes hierarchy (rule 7): key → disjoint keys. */ + contradictionClosure: Record; +} + +export function validatePredicateSet(specs: readonly PredicateSpec[]): PredicateSetValidation { + const errors: string[] = []; + const warnings: string[] = []; + const byKey = new Map(); + + for (const spec of specs) { + if (byKey.has(spec.key)) { + errors.push(`duplicate predicate key "${spec.key}".`); + } + byKey.set(spec.key, spec); + } + + const known = (key: string) => byKey.has(key); + const at = (spec: PredicateSpec) => `predicate "${spec.key}"`; + + for (const spec of specs) { + // Referenced keys must exist. + for (const ref of spec.specializes ?? []) { + if (!known(ref)) errors.push(`${at(spec)}: specializes unknown predicate "${ref}".`); + } + for (const ref of spec.contradicts ?? []) { + if (!known(ref)) errors.push(`${at(spec)}: contradicts unknown predicate "${ref}".`); + } + if (spec.inverse && !known(spec.inverse)) { + errors.push(`${at(spec)}: inverse references unknown predicate "${spec.inverse}".`); + } + if (spec.supersededBy) { + if (!known(spec.supersededBy)) { + errors.push( + `${at(spec)}: supersededBy references unknown predicate "${spec.supersededBy}".` + ); + } else if (byKey.get(spec.supersededBy)?.status === 'deprecated') { + errors.push(`${at(spec)}: supersededBy must point at a non-deprecated predicate.`); + } + } + + // Rule 2 — inverse mirroring + transitivity mirroring (rule 8, algebraic part = error). + if (spec.inverse && known(spec.inverse)) { + const other = byKey.get(spec.inverse) as PredicateSpec; + if (other.inverse !== spec.key) { + errors.push( + `${at(spec)}: inverse "${spec.inverse}" must declare inverse "${spec.key}" back (got "${ + other.inverse ?? 'undefined' + }").` + ); + } + if (Boolean(spec.isTransitive) !== Boolean(other.isTransitive)) { + errors.push( + `${at(spec)}: inverse pair must agree on isTransitive (theorem) — "${spec.key}"=${Boolean( + spec.isTransitive + )}, "${spec.inverse}"=${Boolean(other.isTransitive)}.` + ); + } + // Rule 8 (editorial part) — lint, not error. + if ((spec.temporalNature ?? null) !== (other.temporalNature ?? null)) { + warnings.push( + `${at(spec)}: inverse pair temporalNature differs from "${spec.inverse}" (lint).` + ); + } + if ((spec.claimType ?? null) !== (other.claimType ?? null)) { + warnings.push(`${at(spec)}: inverse pair claimType differs from "${spec.inverse}" (lint).`); + } + // Rule 13 — objectKind 'claim' with an inverse is a modeling smell (lint). + if (spec.objectKind === 'claim') { + warnings.push( + `${at(spec)}: objectKind 'claim' with an inverse ("${spec.inverse}") — the inverse's subject would be a claim (lint).` + ); + } + } + + // Rule 3 — contradicts symmetry. + for (const ref of spec.contradicts ?? []) { + if (known(ref) && !(byKey.get(ref)?.contradicts ?? []).includes(spec.key)) { + errors.push( + `${at(spec)}: contradicts "${ref}" is not declared symmetrically ("${ref}" must contradict "${spec.key}").` + ); + } + } + + // Rule 9 — symmetric + transitive = equivalence relation; allowed only for the identity allow-list. + const EQUIVALENCE_ALLOW_LIST = new Set(['sameAs']); + if (spec.isSymmetric && spec.isTransitive && !EQUIVALENCE_ALLOW_LIST.has(spec.key)) { + warnings.push( + `${at(spec)}: isSymmetric+isTransitive behaves as an equivalence relation; identity behavior is opt-in by key (allow-list: sameAs) (lint).` + ); + } + } + + // Rule 5 — specializes acyclic. DFS over the specializes DAG. + const visitState = new Map(); + const stack: string[] = []; + const detectCycle = (key: string): void => { + visitState.set(key, 1); + stack.push(key); + for (const parent of byKey.get(key)?.specializes ?? []) { + if (!known(parent)) continue; + const state = visitState.get(parent) ?? 0; + if (state === 1) { + errors.push(`specializes cycle: ${[...stack, parent].join(' ⊑ ')}.`); + } else if (state === 0) { + detectCycle(parent); + } + } + stack.pop(); + visitState.set(key, 2); + }; + for (const spec of specs) { + if ((visitState.get(spec.key) ?? 0) === 0) detectCycle(spec.key); + } + + // Ancestors under specializes (transitive closure of the parent relation), for rules 6 & 7. + const ancestorsOf = (key: string): Set => { + const out = new Set(); + const walk = (k: string) => { + for (const parent of byKey.get(k)?.specializes ?? []) { + if (known(parent) && !out.has(parent)) { + out.add(parent); + walk(parent); + } + } + }; + walk(key); + return out; + }; + + // Rule 6 — a predicate may not contradict its own specializes ancestor/descendant. + for (const spec of specs) { + const ancestors = ancestorsOf(spec.key); + for (const ref of spec.contradicts ?? []) { + if (ancestors.has(ref)) { + errors.push( + `${at(spec)}: contradicts "${ref}" which is a specializes-ancestor — asserting "${spec.key}" would entail and contradict it.` + ); + } + } + } + + // Rule 7 — derive the contradiction closure down the hierarchy: P ⊑ Q ∧ Q ⊥ R ⟹ P ⊥ R. + const contradictionClosure: Record = {}; + for (const spec of specs) { + const disjoint = new Set(spec.contradicts ?? []); + for (const ancestor of ancestorsOf(spec.key)) { + for (const ref of byKey.get(ancestor)?.contradicts ?? []) { + if (ref !== spec.key) disjoint.add(ref); + } + } + if (disjoint.size > 0) contradictionClosure[spec.key] = [...disjoint].sort(); + } + + return { errors, warnings, contradictionClosure }; +}