diff --git a/solstone/think/activities.py b/solstone/think/activities.py index 738bfb9c4..d24c6d656 100644 --- a/solstone/think/activities.py +++ b/solstone/think/activities.py @@ -1741,6 +1741,7 @@ def extract_activity_edges(entries: list[dict], ctx: EdgeContext) -> list[dict]: _extract_story_edge_rows( commitments, ctx=ctx, + kind="committed-to", source="commitment", anchor=record_id, ts=ts, @@ -1753,19 +1754,80 @@ def extract_activity_edges(entries: list[dict], ctx: EdgeContext) -> list[dict]: _extract_story_edge_rows( closures, ctx=ctx, + kind="committed-to", source="closure", anchor=record_id, ts=ts, ) ) + decisions = record.get("decisions") + if isinstance(decisions, list): + rows.extend( + _extract_story_edge_rows( + decisions, + ctx=ctx, + kind="decided-with", + source="decision", + anchor=record_id, + ts=ts, + ) + ) + + relations = record.get("relations") + if isinstance(relations, list): + for relation in relations: + if not isinstance(relation, dict): + continue + src = relation.get("from_entity_id") + dst = relation.get("to_entity_id") + if not isinstance(src, str) or not src: + continue + if not isinstance(dst, str) or not dst: + continue + if src == dst: + continue + rows.append( + { + "src": src, + "dst": dst, + "kind": relation.get("kind"), + "src_name": _edge_str(relation.get("from")), + "dst_name": _edge_str(relation.get("to")), + "day": ctx.day, + "facet": ctx.facet, + "source": "relation", + "path": ctx.path, + "anchor": record_id, + "label": _relation_label( + relation.get("note"), + relation.get("quote"), + ), + "ts": ts, + "weight": 1, + } + ) + return rows +def _relation_label(note: Any, quote: Any) -> str: + """Join relation note and quote into the canonical edge label.""" + parts: list[str] = [] + note_text = _edge_str(note) + quote_text = _edge_str(quote) + if note_text: + parts.append(note_text) + if quote_text: + parts.append(f'"{quote_text}"') + return " — ".join(parts) + + def _extract_story_edge_rows( entries: list[Any], *, ctx: EdgeContext, + kind: str, source: str, anchor: str, ts: int, @@ -1786,7 +1848,7 @@ def _extract_story_edge_rows( { "src": owner_id, "dst": counterparty_id, - "kind": "committed-to", + "kind": kind, "src_name": None, "dst_name": None, "day": ctx.day, diff --git a/solstone/think/indexer/edges.py b/solstone/think/indexer/edges.py index bc24ab07f..8b1f201d0 100644 --- a/solstone/think/indexer/edges.py +++ b/solstone/think/indexer/edges.py @@ -31,16 +31,48 @@ logger = logging.getLogger(__name__) EDGES_SCHEMA_VERSION = 1 EDGES_SCHEMA_PATH = "edges:__schema__" KINDS = frozenset( - {"attended-with", "co-present", "spoke-with", "mentioned", "committed-to"} + { + "attended-with", + "co-present", + "spoke-with", + "mentioned", + "committed-to", + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other", + "decided-with", + "messaged-with", + "scheduled-with", + "party-of", + } +) +# Directed edge kinds are stored without endpoint normalization so orientation +# survives insertion, folding, and query. +DIRECTED_KINDS = frozenset( + {"committed-to", "mentioned", "works-at", "reports-to", "uses", "created"} ) -# Shared directional storage contract; the parallel speaker/mention edge-source -# lode relies on these kinds being stored without endpoint normalization. -DIRECTED_KINDS = frozenset({"committed-to", "mentioned"}) KIND_WEIGHTS = { "committed-to": 5, + "works-with": 4, + "works-at": 4, + "reports-to": 4, + "family-of": 4, + "knows": 4, + "uses": 4, + "created": 4, + "other": 4, + "decided-with": 4, "spoke-with": 4, "mentioned": 3, "attended-with": 3, + "messaged-with": 3, + "party-of": 3, + "scheduled-with": 2, "co-present": 1, } """Relationship-strength weights; commitments outweigh passive co-presence.""" diff --git a/tests/test_edges_query.py b/tests/test_edges_query.py index 67a9898a4..91b8b9620 100644 --- a/tests/test_edges_query.py +++ b/tests/test_edges_query.py @@ -44,6 +44,22 @@ CHUNK_COLUMNS = [ ] FILE_COLUMNS = ["path", "mtime"] EDGE_FILE_COLUMNS = ["path", "mtime"] +NEW_KINDS = frozenset( + { + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other", + "decided-with", + "messaged-with", + "scheduled-with", + "party-of", + } +) def _half_life_decay(age_days: int) -> float: @@ -141,8 +157,86 @@ def _edge_rows(journal: Path) -> list[sqlite3.Row]: def test_edge_kind_constant_tables_stay_in_sync(): + assert NEW_KINDS <= edge_index.KINDS assert set(edge_index.KIND_WEIGHTS) == set(edge_index.KINDS) assert edge_index.DIRECTED_KINDS <= edge_index.KINDS + assert {kind: edge_index.KIND_WEIGHTS[kind] for kind in sorted(NEW_KINDS)} == { + "created": 4, + "decided-with": 4, + "family-of": 4, + "knows": 4, + "messaged-with": 3, + "other": 4, + "party-of": 3, + "reports-to": 4, + "scheduled-with": 2, + "uses": 4, + "works-at": 4, + "works-with": 4, + } + assert {"works-at", "reports-to", "uses", "created"} <= edge_index.DIRECTED_KINDS + assert not ( + {"works-with", "family-of", "knows", "other"} & edge_index.DIRECTED_KINDS + ) + + +def test_new_relation_kinds_store_directed_and_undirected_orientation(edges_journal): + scan_journal(str(edges_journal), full=True) + _insert( + edges_journal, + [ + _row( + "edge_z_manager", + "edge_a_report", + "reports-to", + "direction/reports.jsonl", + src_name="Manager Name", + dst_name="Report Name", + ), + _row( + "edge_z_family", + "edge_a_family", + "family-of", + "direction/family.jsonl", + src_name="Z Family", + dst_name="A Family", + ), + ], + ) + + conn = _direct_conn(edges_journal) + try: + rows = { + row["path"]: dict(row) + for row in conn.execute( + """ + SELECT src, dst, kind, directed, src_name, dst_name, path + FROM edges + WHERE path LIKE 'direction/%' + """ + ) + } + finally: + conn.close() + + assert rows["direction/reports.jsonl"] == { + "src": "edge_z_manager", + "dst": "edge_a_report", + "kind": "reports-to", + "directed": 1, + "src_name": "Manager Name", + "dst_name": "Report Name", + "path": "direction/reports.jsonl", + } + assert rows["direction/family.jsonl"] == { + "src": "edge_a_family", + "dst": "edge_z_family", + "kind": "family-of", + "directed": 0, + "src_name": "A Family", + "dst_name": "Z Family", + "path": "direction/family.jsonl", + } def test_entity_network_scores_kinds_decay_and_null_days(edges_journal):