Initial commit with translated description

This commit is contained in:
2026-03-29 13:14:20 +08:00
commit 78895338d3
5 changed files with 1351 additions and 0 deletions

232
SKILL.md Normal file
View File

@@ -0,0 +1,232 @@
---
name: ontology
description: "用于结构化代理记忆和可组合技能的类型化知识图谱。"
---
# Ontology
A typed vocabulary + constraint system for representing knowledge as a verifiable graph.
## Core Concept
Everything is an **entity** with a **type**, **properties**, and **relations** to other entities. Every mutation is validated against type constraints before committing.
```
Entity: { id, type, properties, relations, created, updated }
Relation: { from_id, relation_type, to_id, properties }
```
## When to Use
| Trigger | Action |
|---------|--------|
| "Remember that..." | Create/update entity |
| "What do I know about X?" | Query graph |
| "Link X to Y" | Create relation |
| "Show all tasks for project Z" | Graph traversal |
| "What depends on X?" | Dependency query |
| Planning multi-step work | Model as graph transformations |
| Skill needs shared state | Read/write ontology objects |
## Core Types
```yaml
# Agents & People
Person: { name, email?, phone?, notes? }
Organization: { name, type?, members[] }
# Work
Project: { name, status, goals[], owner? }
Task: { title, status, due?, priority?, assignee?, blockers[] }
Goal: { description, target_date?, metrics[] }
# Time & Place
Event: { title, start, end?, location?, attendees[], recurrence? }
Location: { name, address?, coordinates? }
# Information
Document: { title, path?, url?, summary? }
Message: { content, sender, recipients[], thread? }
Thread: { subject, participants[], messages[] }
Note: { content, tags[], refs[] }
# Resources
Account: { service, username, credential_ref? }
Device: { name, type, identifiers[] }
Credential: { service, secret_ref } # Never store secrets directly
# Meta
Action: { type, target, timestamp, outcome? }
Policy: { scope, rule, enforcement }
```
## Storage
Default: `memory/ontology/graph.jsonl`
```jsonl
{"op":"create","entity":{"id":"p_001","type":"Person","properties":{"name":"Alice"}}}
{"op":"create","entity":{"id":"proj_001","type":"Project","properties":{"name":"Website Redesign","status":"active"}}}
{"op":"relate","from":"proj_001","rel":"has_owner","to":"p_001"}
```
Query via scripts or direct file ops. For complex graphs, migrate to SQLite.
### Append-Only Rule
When working with existing ontology data or schema, **append/merge** changes instead of overwriting files. This preserves history and avoids clobbering prior definitions.
## Workflows
### Create Entity
```bash
python3 scripts/ontology.py create --type Person --props '{"name":"Alice","email":"alice@example.com"}'
```
### Query
```bash
python3 scripts/ontology.py query --type Task --where '{"status":"open"}'
python3 scripts/ontology.py get --id task_001
python3 scripts/ontology.py related --id proj_001 --rel has_task
```
### Link Entities
```bash
python3 scripts/ontology.py relate --from proj_001 --rel has_task --to task_001
```
### Validate
```bash
python3 scripts/ontology.py validate # Check all constraints
```
## Constraints
Define in `memory/ontology/schema.yaml`:
```yaml
types:
Task:
required: [title, status]
status_enum: [open, in_progress, blocked, done]
Event:
required: [title, start]
validate: "end >= start if end exists"
Credential:
required: [service, secret_ref]
forbidden_properties: [password, secret, token] # Force indirection
relations:
has_owner:
from_types: [Project, Task]
to_types: [Person]
cardinality: many_to_one
blocks:
from_types: [Task]
to_types: [Task]
acyclic: true # No circular dependencies
```
## Skill Contract
Skills that use ontology should declare:
```yaml
# In SKILL.md frontmatter or header
ontology:
reads: [Task, Project, Person]
writes: [Task, Action]
preconditions:
- "Task.assignee must exist"
postconditions:
- "Created Task has status=open"
```
## Planning as Graph Transformation
Model multi-step plans as a sequence of graph operations:
```
Plan: "Schedule team meeting and create follow-up tasks"
1. CREATE Event { title: "Team Sync", attendees: [p_001, p_002] }
2. RELATE Event -> has_project -> proj_001
3. CREATE Task { title: "Prepare agenda", assignee: p_001 }
4. RELATE Task -> for_event -> event_001
5. CREATE Task { title: "Send summary", assignee: p_001, blockers: [task_001] }
```
Each step is validated before execution. Rollback on constraint violation.
## Integration Patterns
### With Causal Inference
Log ontology mutations as causal actions:
```python
# When creating/updating entities, also log to causal action log
action = {
"action": "create_entity",
"domain": "ontology",
"context": {"type": "Task", "project": "proj_001"},
"outcome": "created"
}
```
### Cross-Skill Communication
```python
# Email skill creates commitment
commitment = ontology.create("Commitment", {
"source_message": msg_id,
"description": "Send report by Friday",
"due": "2026-01-31"
})
# Task skill picks it up
tasks = ontology.query("Commitment", {"status": "pending"})
for c in tasks:
ontology.create("Task", {
"title": c.description,
"due": c.due,
"source": c.id
})
```
## Quick Start
```bash
# Initialize ontology storage
mkdir -p memory/ontology
touch memory/ontology/graph.jsonl
# Create schema (optional but recommended)
python3 scripts/ontology.py schema-append --data '{
"types": {
"Task": { "required": ["title", "status"] },
"Project": { "required": ["name"] },
"Person": { "required": ["name"] }
}
}'
# Start using
python3 scripts/ontology.py create --type Person --props '{"name":"Alice"}'
python3 scripts/ontology.py list --type Person
```
## References
- `references/schema.md` — Full type definitions and constraint patterns
- `references/queries.md` — Query language and traversal examples
## Instruction Scope
Runtime instructions operate on local files (`memory/ontology/graph.jsonl` and `memory/ontology/schema.yaml`) and provide CLI usage for create/query/relate/validate; this is within scope. The skill reads/writes workspace files and will create the `memory/ontology` directory when used. Validation includes property/enum/forbidden checks, relation type/cardinality validation, acyclicity for relations marked `acyclic: true`, and Event `end >= start` checks; other higher-level constraints may still be documentation-only unless implemented in code.

6
_meta.json Normal file
View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn72dv4fm7ss7swbq47nnpad9x7zy2jh",
"slug": "ontology",
"version": "1.0.4",
"publishedAt": 1773249559725
}

211
references/queries.md Normal file
View File

@@ -0,0 +1,211 @@
# Query Reference
Query patterns and graph traversal examples.
## Basic Queries
### Get by ID
```bash
python3 scripts/ontology.py get --id task_001
```
### List by Type
```bash
# All tasks
python3 scripts/ontology.py list --type Task
# All people
python3 scripts/ontology.py list --type Person
```
### Filter by Properties
```bash
# Open tasks
python3 scripts/ontology.py query --type Task --where '{"status":"open"}'
# High priority tasks
python3 scripts/ontology.py query --type Task --where '{"priority":"high"}'
# Tasks assigned to specific person (by property)
python3 scripts/ontology.py query --type Task --where '{"assignee":"p_001"}'
```
## Relation Queries
### Get Related Entities
```bash
# Tasks belonging to a project (outgoing)
python3 scripts/ontology.py related --id proj_001 --rel has_task
# What projects does this task belong to (incoming)
python3 scripts/ontology.py related --id task_001 --rel part_of --dir incoming
# All relations for an entity (both directions)
python3 scripts/ontology.py related --id p_001 --dir both
```
### Common Patterns
```bash
# Who owns this project?
python3 scripts/ontology.py related --id proj_001 --rel has_owner
# What events is this person attending?
python3 scripts/ontology.py related --id p_001 --rel attendee_of --dir outgoing
# What's blocking this task?
python3 scripts/ontology.py related --id task_001 --rel blocked_by --dir incoming
```
## Programmatic Queries
### Python API
```python
from scripts.ontology import load_graph, query_entities, get_related
# Load the graph
entities, relations = load_graph("memory/ontology/graph.jsonl")
# Query entities
open_tasks = query_entities("Task", {"status": "open"}, "memory/ontology/graph.jsonl")
# Get related
project_tasks = get_related("proj_001", "has_task", "memory/ontology/graph.jsonl")
```
### Complex Queries
```python
# Find all tasks blocked by incomplete dependencies
def find_blocked_tasks(graph_path):
entities, relations = load_graph(graph_path)
blocked = []
for entity in entities.values():
if entity["type"] != "Task":
continue
if entity["properties"].get("status") == "blocked":
# Find what's blocking it
blockers = get_related(entity["id"], "blocked_by", graph_path, "incoming")
incomplete_blockers = [
b for b in blockers
if b["entity"]["properties"].get("status") != "done"
]
if incomplete_blockers:
blocked.append({
"task": entity,
"blockers": incomplete_blockers
})
return blocked
```
### Path Queries
```python
# Find path between two entities
def find_path(from_id, to_id, graph_path, max_depth=5):
entities, relations = load_graph(graph_path)
visited = set()
queue = [(from_id, [])]
while queue:
current, path = queue.pop(0)
if current == to_id:
return path
if current in visited or len(path) >= max_depth:
continue
visited.add(current)
for rel in relations:
if rel["from"] == current and rel["to"] not in visited:
queue.append((rel["to"], path + [rel]))
if rel["to"] == current and rel["from"] not in visited:
queue.append((rel["from"], path + [{**rel, "direction": "incoming"}]))
return None # No path found
```
## Query Patterns by Use Case
### Task Management
```bash
# All my open tasks
python3 scripts/ontology.py query --type Task --where '{"status":"open","assignee":"p_me"}'
# Overdue tasks (requires custom script for date comparison)
# See references/schema.md for date handling
# Tasks with no blockers
python3 scripts/ontology.py query --type Task --where '{"status":"open"}'
# Then filter in code for those with no incoming "blocks" relations
```
### Project Overview
```bash
# All tasks in project
python3 scripts/ontology.py related --id proj_001 --rel has_task
# Project team members
python3 scripts/ontology.py related --id proj_001 --rel has_member
# Project goals
python3 scripts/ontology.py related --id proj_001 --rel has_goal
```
### People & Contacts
```bash
# All people
python3 scripts/ontology.py list --type Person
# People in an organization
python3 scripts/ontology.py related --id org_001 --rel has_member
# What's assigned to this person
python3 scripts/ontology.py related --id p_001 --rel assigned_to --dir incoming
```
### Events & Calendar
```bash
# All events
python3 scripts/ontology.py list --type Event
# Events at a location
python3 scripts/ontology.py related --id loc_001 --rel located_at --dir incoming
# Event attendees
python3 scripts/ontology.py related --id event_001 --rel attendee_of --dir incoming
```
## Aggregations
For complex aggregations, use Python:
```python
from collections import Counter
def task_status_summary(project_id, graph_path):
"""Count tasks by status for a project."""
tasks = get_related(project_id, "has_task", graph_path)
statuses = Counter(t["entity"]["properties"].get("status", "unknown") for t in tasks)
return dict(statuses)
def workload_by_person(graph_path):
"""Count open tasks per person."""
open_tasks = query_entities("Task", {"status": "open"}, graph_path)
workload = Counter(t["properties"].get("assignee") for t in open_tasks)
return dict(workload)
```

322
references/schema.md Normal file
View File

@@ -0,0 +1,322 @@
# Ontology Schema Reference
Full type definitions and constraint patterns for the ontology graph.
## Core Types
### Agents & People
```yaml
Person:
required: [name]
properties:
name: string
email: string?
phone: string?
organization: ref(Organization)?
notes: string?
tags: string[]?
Organization:
required: [name]
properties:
name: string
type: enum(company, team, community, government, other)?
website: url?
members: ref(Person)[]?
```
### Work Management
```yaml
Project:
required: [name]
properties:
name: string
description: string?
status: enum(planning, active, paused, completed, archived)
owner: ref(Person)?
team: ref(Person)[]?
goals: ref(Goal)[]?
start_date: date?
end_date: date?
tags: string[]?
Task:
required: [title, status]
properties:
title: string
description: string?
status: enum(open, in_progress, blocked, done, cancelled)
priority: enum(low, medium, high, urgent)?
assignee: ref(Person)?
project: ref(Project)?
due: datetime?
estimate_hours: number?
blockers: ref(Task)[]?
tags: string[]?
Goal:
required: [description]
properties:
description: string
target_date: date?
status: enum(active, achieved, abandoned)?
metrics: object[]?
key_results: string[]?
```
### Time & Location
```yaml
Event:
required: [title, start]
properties:
title: string
description: string?
start: datetime
end: datetime?
location: ref(Location)?
attendees: ref(Person)[]?
recurrence: object? # iCal RRULE format
status: enum(confirmed, tentative, cancelled)?
reminders: object[]?
Location:
required: [name]
properties:
name: string
address: string?
city: string?
country: string?
coordinates: object? # {lat, lng}
timezone: string?
```
### Information
```yaml
Document:
required: [title]
properties:
title: string
path: string? # Local file path
url: url? # Remote URL
mime_type: string?
summary: string?
content_hash: string?
tags: string[]?
Message:
required: [content, sender]
properties:
content: string
sender: ref(Person)
recipients: ref(Person)[]
thread: ref(Thread)?
timestamp: datetime
platform: string? # email, slack, whatsapp, etc.
external_id: string?
Thread:
required: [subject]
properties:
subject: string
participants: ref(Person)[]
messages: ref(Message)[]
status: enum(active, archived)?
last_activity: datetime?
Note:
required: [content]
properties:
content: string
title: string?
tags: string[]?
refs: ref(Entity)[]? # Links to any entity
created: datetime
```
### Resources
```yaml
Account:
required: [service, username]
properties:
service: string # github, gmail, aws, etc.
username: string
url: url?
credential_ref: ref(Credential)?
Device:
required: [name, type]
properties:
name: string
type: enum(computer, phone, tablet, server, iot, other)
os: string?
identifiers: object? # {mac, serial, etc.}
owner: ref(Person)?
Credential:
required: [service, secret_ref]
forbidden_properties: [password, secret, token, key, api_key]
properties:
service: string
secret_ref: string # Reference to secret store (e.g., "keychain:github-token")
expires: datetime?
scope: string[]?
```
### Meta
```yaml
Action:
required: [type, target, timestamp]
properties:
type: string # create, update, delete, send, etc.
target: ref(Entity)
timestamp: datetime
actor: ref(Person|Agent)?
outcome: enum(success, failure, pending)?
details: object?
Policy:
required: [scope, rule]
properties:
scope: string # What this policy applies to
rule: string # The constraint in natural language or code
enforcement: enum(block, warn, log)
enabled: boolean
```
## Relation Types
### Ownership & Assignment
```yaml
owns:
from_types: [Person, Organization]
to_types: [Account, Device, Document, Project]
cardinality: one_to_many
has_owner:
from_types: [Project, Task, Document]
to_types: [Person]
cardinality: many_to_one
assigned_to:
from_types: [Task]
to_types: [Person]
cardinality: many_to_one
```
### Hierarchy & Containment
```yaml
has_task:
from_types: [Project]
to_types: [Task]
cardinality: one_to_many
has_goal:
from_types: [Project]
to_types: [Goal]
cardinality: one_to_many
member_of:
from_types: [Person]
to_types: [Organization]
cardinality: many_to_many
part_of:
from_types: [Task, Document, Event]
to_types: [Project]
cardinality: many_to_one
```
### Dependencies
```yaml
blocks:
from_types: [Task]
to_types: [Task]
acyclic: true # Prevents circular dependencies
cardinality: many_to_many
depends_on:
from_types: [Task, Project]
to_types: [Task, Project, Event]
acyclic: true
cardinality: many_to_many
requires:
from_types: [Action]
to_types: [Credential, Policy]
cardinality: many_to_many
```
### References
```yaml
mentions:
from_types: [Document, Message, Note]
to_types: [Person, Project, Task, Event]
cardinality: many_to_many
references:
from_types: [Document, Note]
to_types: [Document, Note]
cardinality: many_to_many
follows_up:
from_types: [Task, Event]
to_types: [Event, Message]
cardinality: many_to_one
```
### Events
```yaml
attendee_of:
from_types: [Person]
to_types: [Event]
cardinality: many_to_many
properties:
status: enum(accepted, declined, tentative, pending)
located_at:
from_types: [Event, Person, Device]
to_types: [Location]
cardinality: many_to_one
```
## Global Constraints
```yaml
constraints:
# Credentials must never store secrets directly
- type: Credential
rule: "forbidden_properties: [password, secret, token]"
message: "Credentials must use secret_ref to reference external secret storage"
# Tasks must have valid status transitions
- type: Task
rule: "status transitions: open -> in_progress -> (done|blocked) -> done"
enforcement: warn
# Events must have end >= start
- type: Event
rule: "if end exists: end >= start"
message: "Event end time must be after start time"
# No orphan tasks (should belong to a project or have explicit owner)
- type: Task
rule: "has_relation(part_of, Project) OR has_property(owner)"
enforcement: warn
message: "Task should belong to a project or have an explicit owner"
# Circular dependency prevention
- relation: blocks
rule: "acyclic"
message: "Circular task dependencies are not allowed"
```

580
scripts/ontology.py Normal file
View File

@@ -0,0 +1,580 @@
#!/usr/bin/env python3
"""
Ontology graph operations: create, query, relate, validate.
Usage:
python ontology.py create --type Person --props '{"name":"Alice"}'
python ontology.py get --id p_001
python ontology.py query --type Task --where '{"status":"open"}'
python ontology.py relate --from proj_001 --rel has_task --to task_001
python ontology.py related --id proj_001 --rel has_task
python ontology.py list --type Person
python ontology.py delete --id p_001
python ontology.py validate
"""
import argparse
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
DEFAULT_GRAPH_PATH = "memory/ontology/graph.jsonl"
DEFAULT_SCHEMA_PATH = "memory/ontology/schema.yaml"
def resolve_safe_path(
user_path: str,
*,
root: Path | None = None,
must_exist: bool = False,
label: str = "path",
) -> Path:
"""Resolve user path within root and reject traversal outside it."""
if not user_path or not user_path.strip():
raise SystemExit(f"Invalid {label}: empty path")
safe_root = (root or Path.cwd()).resolve()
candidate = Path(user_path).expanduser()
if not candidate.is_absolute():
candidate = safe_root / candidate
try:
resolved = candidate.resolve(strict=False)
except OSError as exc:
raise SystemExit(f"Invalid {label}: {exc}") from exc
try:
resolved.relative_to(safe_root)
except ValueError:
raise SystemExit(
f"Invalid {label}: must stay within workspace root '{safe_root}'"
)
if must_exist and not resolved.exists():
raise SystemExit(f"Invalid {label}: file not found '{resolved}'")
return resolved
def generate_id(type_name: str) -> str:
"""Generate a unique ID for an entity."""
prefix = type_name.lower()[:4]
suffix = uuid.uuid4().hex[:8]
return f"{prefix}_{suffix}"
def load_graph(path: str) -> tuple[dict, list]:
"""Load entities and relations from graph file."""
entities = {}
relations = []
graph_path = Path(path)
if not graph_path.exists():
return entities, relations
with open(graph_path) as f:
for line in f:
line = line.strip()
if not line:
continue
record = json.loads(line)
op = record.get("op")
if op == "create":
entity = record["entity"]
entities[entity["id"]] = entity
elif op == "update":
entity_id = record["id"]
if entity_id in entities:
entities[entity_id]["properties"].update(record.get("properties", {}))
entities[entity_id]["updated"] = record.get("timestamp")
elif op == "delete":
entity_id = record["id"]
entities.pop(entity_id, None)
elif op == "relate":
relations.append({
"from": record["from"],
"rel": record["rel"],
"to": record["to"],
"properties": record.get("properties", {})
})
elif op == "unrelate":
relations = [r for r in relations
if not (r["from"] == record["from"]
and r["rel"] == record["rel"]
and r["to"] == record["to"])]
return entities, relations
def append_op(path: str, record: dict):
"""Append an operation to the graph file."""
graph_path = Path(path)
graph_path.parent.mkdir(parents=True, exist_ok=True)
with open(graph_path, "a") as f:
f.write(json.dumps(record) + "\n")
def create_entity(type_name: str, properties: dict, graph_path: str, entity_id: str = None) -> dict:
"""Create a new entity."""
entity_id = entity_id or generate_id(type_name)
timestamp = datetime.now(timezone.utc).isoformat()
entity = {
"id": entity_id,
"type": type_name,
"properties": properties,
"created": timestamp,
"updated": timestamp
}
record = {"op": "create", "entity": entity, "timestamp": timestamp}
append_op(graph_path, record)
return entity
def get_entity(entity_id: str, graph_path: str) -> dict | None:
"""Get entity by ID."""
entities, _ = load_graph(graph_path)
return entities.get(entity_id)
def query_entities(type_name: str, where: dict, graph_path: str) -> list:
"""Query entities by type and properties."""
entities, _ = load_graph(graph_path)
results = []
for entity in entities.values():
if type_name and entity["type"] != type_name:
continue
match = True
for key, value in where.items():
if entity["properties"].get(key) != value:
match = False
break
if match:
results.append(entity)
return results
def list_entities(type_name: str, graph_path: str) -> list:
"""List all entities of a type."""
entities, _ = load_graph(graph_path)
if type_name:
return [e for e in entities.values() if e["type"] == type_name]
return list(entities.values())
def update_entity(entity_id: str, properties: dict, graph_path: str) -> dict | None:
"""Update entity properties."""
entities, _ = load_graph(graph_path)
if entity_id not in entities:
return None
timestamp = datetime.now(timezone.utc).isoformat()
record = {"op": "update", "id": entity_id, "properties": properties, "timestamp": timestamp}
append_op(graph_path, record)
entities[entity_id]["properties"].update(properties)
entities[entity_id]["updated"] = timestamp
return entities[entity_id]
def delete_entity(entity_id: str, graph_path: str) -> bool:
"""Delete an entity."""
entities, _ = load_graph(graph_path)
if entity_id not in entities:
return False
timestamp = datetime.now(timezone.utc).isoformat()
record = {"op": "delete", "id": entity_id, "timestamp": timestamp}
append_op(graph_path, record)
return True
def create_relation(from_id: str, rel_type: str, to_id: str, properties: dict, graph_path: str):
"""Create a relation between entities."""
timestamp = datetime.now(timezone.utc).isoformat()
record = {
"op": "relate",
"from": from_id,
"rel": rel_type,
"to": to_id,
"properties": properties,
"timestamp": timestamp
}
append_op(graph_path, record)
return record
def get_related(entity_id: str, rel_type: str, graph_path: str, direction: str = "outgoing") -> list:
"""Get related entities."""
entities, relations = load_graph(graph_path)
results = []
for rel in relations:
if direction == "outgoing" and rel["from"] == entity_id:
if not rel_type or rel["rel"] == rel_type:
if rel["to"] in entities:
results.append({
"relation": rel["rel"],
"entity": entities[rel["to"]]
})
elif direction == "incoming" and rel["to"] == entity_id:
if not rel_type or rel["rel"] == rel_type:
if rel["from"] in entities:
results.append({
"relation": rel["rel"],
"entity": entities[rel["from"]]
})
elif direction == "both":
if rel["from"] == entity_id or rel["to"] == entity_id:
if not rel_type or rel["rel"] == rel_type:
other_id = rel["to"] if rel["from"] == entity_id else rel["from"]
if other_id in entities:
results.append({
"relation": rel["rel"],
"direction": "outgoing" if rel["from"] == entity_id else "incoming",
"entity": entities[other_id]
})
return results
def validate_graph(graph_path: str, schema_path: str) -> list:
"""Validate graph against schema constraints."""
entities, relations = load_graph(graph_path)
errors = []
# Load schema if exists
schema = load_schema(schema_path)
type_schemas = schema.get("types", {})
relation_schemas = schema.get("relations", {})
global_constraints = schema.get("constraints", [])
for entity_id, entity in entities.items():
type_name = entity["type"]
type_schema = type_schemas.get(type_name, {})
# Check required properties
required = type_schema.get("required", [])
for prop in required:
if prop not in entity["properties"]:
errors.append(f"{entity_id}: missing required property '{prop}'")
# Check forbidden properties
forbidden = type_schema.get("forbidden_properties", [])
for prop in forbidden:
if prop in entity["properties"]:
errors.append(f"{entity_id}: contains forbidden property '{prop}'")
# Check enum values
for prop, allowed in type_schema.items():
if prop.endswith("_enum"):
field = prop.replace("_enum", "")
value = entity["properties"].get(field)
if value and value not in allowed:
errors.append(f"{entity_id}: '{field}' must be one of {allowed}, got '{value}'")
# Relation constraints (type + cardinality + acyclicity)
rel_index = {}
for rel in relations:
rel_index.setdefault(rel["rel"], []).append(rel)
for rel_type, rel_schema in relation_schemas.items():
rels = rel_index.get(rel_type, [])
from_types = rel_schema.get("from_types", [])
to_types = rel_schema.get("to_types", [])
cardinality = rel_schema.get("cardinality")
acyclic = rel_schema.get("acyclic", False)
# Type checks
for rel in rels:
from_entity = entities.get(rel["from"])
to_entity = entities.get(rel["to"])
if not from_entity or not to_entity:
errors.append(f"{rel_type}: relation references missing entity ({rel['from']} -> {rel['to']})")
continue
if from_types and from_entity["type"] not in from_types:
errors.append(
f"{rel_type}: from entity {rel['from']} type {from_entity['type']} not in {from_types}"
)
if to_types and to_entity["type"] not in to_types:
errors.append(
f"{rel_type}: to entity {rel['to']} type {to_entity['type']} not in {to_types}"
)
# Cardinality checks
if cardinality in ("one_to_one", "one_to_many", "many_to_one"):
from_counts = {}
to_counts = {}
for rel in rels:
from_counts[rel["from"]] = from_counts.get(rel["from"], 0) + 1
to_counts[rel["to"]] = to_counts.get(rel["to"], 0) + 1
if cardinality in ("one_to_one", "many_to_one"):
for from_id, count in from_counts.items():
if count > 1:
errors.append(f"{rel_type}: from entity {from_id} violates cardinality {cardinality}")
if cardinality in ("one_to_one", "one_to_many"):
for to_id, count in to_counts.items():
if count > 1:
errors.append(f"{rel_type}: to entity {to_id} violates cardinality {cardinality}")
# Acyclic checks
if acyclic:
graph = {}
for rel in rels:
graph.setdefault(rel["from"], []).append(rel["to"])
visited = {}
def dfs(node, stack):
visited[node] = True
stack.add(node)
for nxt in graph.get(node, []):
if nxt in stack:
return True
if not visited.get(nxt, False):
if dfs(nxt, stack):
return True
stack.remove(node)
return False
for node in graph:
if not visited.get(node, False):
if dfs(node, set()):
errors.append(f"{rel_type}: cyclic dependency detected")
break
# Global constraints (limited enforcement)
for constraint in global_constraints:
ctype = constraint.get("type")
relation = constraint.get("relation")
rule = (constraint.get("rule") or "").strip().lower()
if ctype == "Event" and "end" in rule and "start" in rule:
for entity_id, entity in entities.items():
if entity["type"] != "Event":
continue
start = entity["properties"].get("start")
end = entity["properties"].get("end")
if start and end:
try:
start_dt = datetime.fromisoformat(start)
end_dt = datetime.fromisoformat(end)
if end_dt < start_dt:
errors.append(f"{entity_id}: end must be >= start")
except ValueError:
errors.append(f"{entity_id}: invalid datetime format in start/end")
if relation and rule == "acyclic":
# Already enforced above via relations schema
continue
return errors
def load_schema(schema_path: str) -> dict:
"""Load schema from YAML if it exists."""
schema = {}
schema_file = Path(schema_path)
if schema_file.exists():
import yaml
with open(schema_file) as f:
schema = yaml.safe_load(f) or {}
return schema
def write_schema(schema_path: str, schema: dict) -> None:
"""Write schema to YAML."""
schema_file = Path(schema_path)
schema_file.parent.mkdir(parents=True, exist_ok=True)
import yaml
with open(schema_file, "w") as f:
yaml.safe_dump(schema, f, sort_keys=False)
def merge_schema(base: dict, incoming: dict) -> dict:
"""Merge incoming schema into base, appending lists and deep-merging dicts."""
for key, value in (incoming or {}).items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
base[key] = merge_schema(base[key], value)
elif key in base and isinstance(base[key], list) and isinstance(value, list):
base[key] = base[key] + [v for v in value if v not in base[key]]
else:
base[key] = value
return base
def append_schema(schema_path: str, incoming: dict) -> dict:
"""Append/merge schema fragment into existing schema."""
base = load_schema(schema_path)
merged = merge_schema(base, incoming)
write_schema(schema_path, merged)
return merged
def main():
parser = argparse.ArgumentParser(description="Ontology graph operations")
subparsers = parser.add_subparsers(dest="command", required=True)
# Create
create_p = subparsers.add_parser("create", help="Create entity")
create_p.add_argument("--type", "-t", required=True, help="Entity type")
create_p.add_argument("--props", "-p", default="{}", help="Properties JSON")
create_p.add_argument("--id", help="Entity ID (auto-generated if not provided)")
create_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Get
get_p = subparsers.add_parser("get", help="Get entity by ID")
get_p.add_argument("--id", required=True, help="Entity ID")
get_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Query
query_p = subparsers.add_parser("query", help="Query entities")
query_p.add_argument("--type", "-t", help="Entity type")
query_p.add_argument("--where", "-w", default="{}", help="Filter JSON")
query_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# List
list_p = subparsers.add_parser("list", help="List entities")
list_p.add_argument("--type", "-t", help="Entity type")
list_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Update
update_p = subparsers.add_parser("update", help="Update entity")
update_p.add_argument("--id", required=True, help="Entity ID")
update_p.add_argument("--props", "-p", required=True, help="Properties JSON")
update_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Delete
delete_p = subparsers.add_parser("delete", help="Delete entity")
delete_p.add_argument("--id", required=True, help="Entity ID")
delete_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Relate
relate_p = subparsers.add_parser("relate", help="Create relation")
relate_p.add_argument("--from", dest="from_id", required=True, help="From entity ID")
relate_p.add_argument("--rel", "-r", required=True, help="Relation type")
relate_p.add_argument("--to", dest="to_id", required=True, help="To entity ID")
relate_p.add_argument("--props", "-p", default="{}", help="Relation properties JSON")
relate_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Related
related_p = subparsers.add_parser("related", help="Get related entities")
related_p.add_argument("--id", required=True, help="Entity ID")
related_p.add_argument("--rel", "-r", help="Relation type filter")
related_p.add_argument("--dir", "-d", choices=["outgoing", "incoming", "both"], default="outgoing")
related_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
# Validate
validate_p = subparsers.add_parser("validate", help="Validate graph")
validate_p.add_argument("--graph", "-g", default=DEFAULT_GRAPH_PATH)
validate_p.add_argument("--schema", "-s", default=DEFAULT_SCHEMA_PATH)
# Schema append
schema_p = subparsers.add_parser("schema-append", help="Append/merge schema fragment")
schema_p.add_argument("--schema", "-s", default=DEFAULT_SCHEMA_PATH)
schema_p.add_argument("--data", "-d", help="Schema fragment as JSON")
schema_p.add_argument("--file", "-f", help="Schema fragment file (YAML or JSON)")
args = parser.parse_args()
workspace_root = Path.cwd().resolve()
if hasattr(args, "graph"):
args.graph = str(
resolve_safe_path(args.graph, root=workspace_root, label="graph path")
)
if hasattr(args, "schema"):
args.schema = str(
resolve_safe_path(args.schema, root=workspace_root, label="schema path")
)
if hasattr(args, "file") and args.file:
args.file = str(
resolve_safe_path(
args.file, root=workspace_root, must_exist=True, label="schema file"
)
)
if args.command == "create":
props = json.loads(args.props)
entity = create_entity(args.type, props, args.graph, args.id)
print(json.dumps(entity, indent=2))
elif args.command == "get":
entity = get_entity(args.id, args.graph)
if entity:
print(json.dumps(entity, indent=2))
else:
print(f"Entity not found: {args.id}")
elif args.command == "query":
where = json.loads(args.where)
results = query_entities(args.type, where, args.graph)
print(json.dumps(results, indent=2))
elif args.command == "list":
results = list_entities(args.type, args.graph)
print(json.dumps(results, indent=2))
elif args.command == "update":
props = json.loads(args.props)
entity = update_entity(args.id, props, args.graph)
if entity:
print(json.dumps(entity, indent=2))
else:
print(f"Entity not found: {args.id}")
elif args.command == "delete":
if delete_entity(args.id, args.graph):
print(f"Deleted: {args.id}")
else:
print(f"Entity not found: {args.id}")
elif args.command == "relate":
props = json.loads(args.props)
rel = create_relation(args.from_id, args.rel, args.to_id, props, args.graph)
print(json.dumps(rel, indent=2))
elif args.command == "related":
results = get_related(args.id, args.rel, args.graph, args.dir)
print(json.dumps(results, indent=2))
elif args.command == "validate":
errors = validate_graph(args.graph, args.schema)
if errors:
print("Validation errors:")
for err in errors:
print(f" - {err}")
else:
print("Graph is valid.")
elif args.command == "schema-append":
if not args.data and not args.file:
raise SystemExit("schema-append requires --data or --file")
incoming = {}
if args.data:
incoming = json.loads(args.data)
else:
path = Path(args.file)
if path.suffix.lower() == ".json":
with open(path) as f:
incoming = json.load(f)
else:
import yaml
with open(path) as f:
incoming = yaml.safe_load(f) or {}
merged = append_schema(args.schema, incoming)
print(json.dumps(merged, indent=2))
if __name__ == "__main__":
main()