Add Phase 4 knowledge graph: nodes, cells, facts, edges, gRPC service

Build the knowledge graph pillar with the kg package:
- Node: hierarchical notes with parent/children, C2 wiki-style naming,
  shared tag/category pool with artifacts
- Cell: content units (markdown, code, plain) with ordinal ordering
- Fact: EAV tuples with transaction timestamps and retraction support
- Edge: directed graph links (child, parent, related, artifact_link)

Includes schema migration (002_knowledge_graph.sql), protobuf definitions
(kg.proto), full gRPC KnowledgeGraphService implementation, CLI commands
(node create/get), and comprehensive test coverage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 10:05:43 -07:00
parent a336dc1ebb
commit 051a85d846
14 changed files with 3283 additions and 6 deletions

121
kg/fact.go Normal file
View File

@@ -0,0 +1,121 @@
package kg
import (
"context"
"database/sql"
"fmt"
"time"
"git.wntrmute.dev/kyle/exo/core"
)
// Fact records an entity-attribute-value relationship with transactional
// history. A Fact with Retraction=true marks a previous fact as no longer
// valid without deleting history.
type Fact struct {
ID string
EntityID string
EntityName string
AttributeID string
AttributeName string
Value core.Value
TxTimestamp time.Time
Retraction bool
}
// NewFact creates a Fact with a new UUID and current transaction timestamp.
func NewFact(entityID, entityName, attrID, attrName string, value core.Value) *Fact {
return &Fact{
ID: core.NewUUID(),
EntityID: entityID,
EntityName: entityName,
AttributeID: attrID,
AttributeName: attrName,
Value: value,
TxTimestamp: time.Now().UTC(),
}
}
// Retract creates a retraction Fact for this fact's entity+attribute.
func (f *Fact) Retract() *Fact {
return &Fact{
ID: core.NewUUID(),
EntityID: f.EntityID,
EntityName: f.EntityName,
AttributeID: f.AttributeID,
AttributeName: f.AttributeName,
Value: f.Value,
TxTimestamp: time.Now().UTC(),
Retraction: true,
}
}
// Store persists a Fact to the database.
func (f *Fact) Store(ctx context.Context, tx *sql.Tx) error {
retraction := 0
if f.Retraction {
retraction = 1
}
_, err := tx.ExecContext(ctx,
`INSERT INTO facts (id, entity_id, entity_name, attribute_id, attribute_name, value_contents, value_type, tx_timestamp, retraction) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.EntityID, f.EntityName, f.AttributeID, f.AttributeName,
f.Value.Contents, f.Value.Type, f.TxTimestamp.Unix(), retraction)
if err != nil {
return fmt.Errorf("kg: failed to store fact: %w", err)
}
return nil
}
// GetFactsForEntity retrieves all facts for a given entity, ordered by
// transaction timestamp. Includes both assertions and retractions.
func GetFactsForEntity(ctx context.Context, tx *sql.Tx, entityID string) ([]*Fact, error) {
rows, err := tx.QueryContext(ctx,
`SELECT id, entity_name, attribute_id, attribute_name, value_contents, value_type, tx_timestamp, retraction FROM facts WHERE entity_id=? ORDER BY tx_timestamp`,
entityID)
if err != nil {
return nil, fmt.Errorf("kg: failed to retrieve facts for entity: %w", err)
}
defer func() { _ = rows.Close() }()
var facts []*Fact
for rows.Next() {
f := &Fact{EntityID: entityID}
var txTS int64
var retraction int
if err := rows.Scan(&f.ID, &f.EntityName, &f.AttributeID, &f.AttributeName,
&f.Value.Contents, &f.Value.Type, &txTS, &retraction); err != nil {
return nil, fmt.Errorf("kg: failed to scan fact: %w", err)
}
f.TxTimestamp = time.Unix(txTS, 0)
f.Retraction = retraction != 0
facts = append(facts, f)
}
return facts, rows.Err()
}
// GetCurrentFactsForEntity retrieves the current (non-retracted) facts for
// an entity by applying the retraction logic: for each entity+attribute pair,
// only the most recent non-retracted assertion is returned.
func GetCurrentFactsForEntity(ctx context.Context, tx *sql.Tx, entityID string) ([]*Fact, error) {
allFacts, err := GetFactsForEntity(ctx, tx, entityID)
if err != nil {
return nil, err
}
// Track the latest fact per attribute. A retraction cancels the previous assertion.
latest := map[string]*Fact{}
for _, f := range allFacts {
if f.Retraction {
delete(latest, f.AttributeID)
} else {
latest[f.AttributeID] = f
}
}
result := make([]*Fact, 0, len(latest))
for _, f := range latest {
result = append(result, f)
}
return result, nil
}