diff --git a/solstone/apps/entities/tests/test_merge.py b/solstone/apps/entities/tests/test_merge.py index 7aa49366d..ee537a837 100644 --- a/solstone/apps/entities/tests/test_merge.py +++ b/solstone/apps/entities/tests/test_merge.py @@ -156,7 +156,8 @@ def _edge_rows_with_kind(env) -> list[dict]: return [ dict(row) for row in conn.execute( - "SELECT src, dst, kind, path FROM edges ORDER BY kind, path, src, dst" + "SELECT src, dst, kind, source, path " + "FROM edges ORDER BY kind, path, src, dst" ).fetchall() ] finally: @@ -566,6 +567,60 @@ def test_merge_then_rebuild_converges_all_edge_source_kinds(speakers_env): for entity_id in (source_id, target_id, peer_id, mention_id): env.create_facet_relationship("work", entity_id) + source_obs_path = ( + env.journal / "facets" / "work" / "entities" / source_id / "observations.jsonl" + ) + source_obs_path.write_text( + json.dumps( + { + "content": "source observation carried forward", + "observed_at": 1700000001000, + } + ) + + "\n", + encoding="utf-8", + ) + target_obs_path = ( + env.journal / "facets" / "work" / "entities" / target_id / "observations.jsonl" + ) + target_obs_path.write_text( + json.dumps( + { + "content": "target relation to source", + "observed_at": 1700000002000, + "source_day": day, + "relation": { + "kind": "family-of", + "target_entity_id": source_id, + "target_name": "Merge Edge Source", + "note": "Self after merge", + }, + } + ) + + "\n", + encoding="utf-8", + ) + peer_obs_path = ( + env.journal / "facets" / "work" / "entities" / peer_id / "observations.jsonl" + ) + peer_obs_path.write_text( + json.dumps( + { + "content": "peer observation relation to source", + "observed_at": 1700000003000, + "source_day": day, + "relation": { + "kind": "knows", + "target_entity_id": source_id, + "target_name": "Merge Edge Source", + "note": "Observation-backed relation", + }, + } + ) + + "\n", + encoding="utf-8", + ) + detected_path = env.journal / "facets" / "work" / "entities" / f"{day}.jsonl" detected_path.parent.mkdir(parents=True, exist_ok=True) detected_path.write_text( @@ -600,6 +655,36 @@ def test_merge_then_rebuild_converges_all_edge_source_kinds(speakers_env): {"role": "attendee", "entity_id": source_id}, {"role": "attendee", "entity_id": peer_id}, ], + "relations": [ + { + "from": "Merge Edge Source", + "to": "Merge Edge Peer", + "from_entity_id": source_id, + "to_entity_id": peer_id, + "kind": kind, + "note": f"{kind} relation", + "quote": None, + } + for kind in ( + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other", + ) + ], + "decisions": [ + { + "owner": "Merge Edge Source", + "counterparty": "Merge Edge Peer", + "owner_entity_id": source_id, + "counterparty_entity_id": peer_id, + "action": "Use the merge convergence fixture", + } + ], "commitments": [ { "owner_entity_id": source_id, @@ -610,29 +695,155 @@ def test_merge_then_rebuild_converges_all_edge_source_kinds(speakers_env): } ], ) + segment_dir = env.journal / "chronicle" / day / STREAM / segment_key + (segment_dir / "screen.jsonl").write_text( + "\n".join( + [ + json.dumps({"raw": "screen.png", "model": "fixture"}), + json.dumps( + { + "timestamp": 0, + "content": { + "messaging": { + "view": "conversation", + "app": "Signal", + "thread": "Merge Edge Thread", + "messages": [ + { + "sender": "Merge Edge Source", + "timestamp": "2024-01-01T12:00:00Z", + "subject": "", + "text": "Can you review this merge?", + }, + { + "sender": "Merge Edge Peer", + "timestamp": "2024-01-01T12:00:30Z", + "subject": "", + "text": "Yes, I can.", + }, + ], + }, + "calendar": { + "view": "day", + "app": "Calendar", + "events": [ + { + "title": "Merge Edge Calendar", + "start": "2024-01-01T12:30:00Z", + "end": "2024-01-01T13:00:00Z", + "calendar": "Work", + "guests": [ + "Merge Edge Source", + "Merge Edge Peer", + ], + } + ], + }, + }, + } + ), + ] + ) + + "\n", + encoding="utf-8", + ) + talents_dir = segment_dir / "talents" + talents_dir.mkdir(parents=True, exist_ok=True) + (talents_dir / "documents.json").write_text( + json.dumps( + { + "overview": "Merge convergence document.", + "parties": [ + {"name": "Merge Edge Source", "role": "author"}, + {"name": "Merge Edge Peer", "role": "reviewer"}, + ], + "key_provisions": [], + "assets": [], + "conditions": [], + "important_dates": [], + "summary": "Merge source and peer are parties.", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) rebuild_edges(str(env.journal)) pre_rows = _edge_rows_with_kind(env) source_kinds = { row["kind"] for row in pre_rows if source_id in {row["src"], row["dst"]} } + new_kinds = { + "works-with", + "works-at", + "reports-to", + "family-of", + "knows", + "uses", + "created", + "other", + "decided-with", + "messaged-with", + "scheduled-with", + "party-of", + } assert source_kinds == { "attended-with", "co-present", "committed-to", + "decided-with", + "family-of", + "knows", "mentioned", + "messaged-with", + "party-of", + "scheduled-with", "spoke-with", + "works-at", + "works-with", + "reports-to", + "uses", + "created", + "other", } + assert new_kinds <= source_kinds + assert any( + row["source"] == "observation" + and row["kind"] == "knows" + and row["dst"] == source_id + for row in pre_rows + ) result = merge_mod.merge_entity(source_id, target_id, commit=True) assert result["merged"] is True - assert result["activities"]["fields_rewritten"] == 2 + assert result["facets"]["observations_appended"] == 1 + assert result["facets"]["observation_relations_rewritten"] == 2 + assert result["activities"]["fields_rewritten"] == 11 assert all( source_id not in {row["src"], row["dst"]} for row in _edge_rows_with_kind(env) ) + target_observations = [ + json.loads(line) + for line in target_obs_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert {item["content"] for item in target_observations} == { + "target relation to source", + "source observation carried forward", + } + assert target_observations[0]["relation"]["target_entity_id"] == target_id + peer_observations = [ + json.loads(line) + for line in peer_obs_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert peer_observations[0]["relation"]["target_entity_id"] == target_id + folded_hash = _edge_hash(env) rebuild_edges(str(env.journal)) + assert _edge_hash(env) == folded_hash rebuilt_rows = _edge_rows_with_kind(env) assert all(source_id not in {row["src"], row["dst"]} for row in rebuilt_rows) target_kinds = { @@ -642,9 +853,28 @@ def test_merge_then_rebuild_converges_all_edge_source_kinds(speakers_env): "attended-with", "co-present", "committed-to", + "decided-with", + "family-of", + "knows", "mentioned", + "messaged-with", + "party-of", + "scheduled-with", "spoke-with", + "works-at", + "works-with", + "reports-to", + "uses", + "created", + "other", } + assert new_kinds <= target_kinds + assert any( + row["source"] == "observation" + and row["kind"] == "knows" + and target_id in {row["src"], row["dst"]} + for row in rebuilt_rows + ) first_hash = _edge_hash(env) rebuild_edges(str(env.journal)) diff --git a/solstone/think/document_edges.py b/solstone/think/document_edges.py new file mode 100644 index 000000000..ba9722e37 --- /dev/null +++ b/solstone/think/document_edges.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Derived edge extraction from talent document summaries.""" + +from __future__ import annotations + +from itertools import combinations +from typing import Any + +from solstone.think.edge_sources import EdgeContext, segment_ref +from solstone.think.utils import segment_start_ts_ms + + +def extract_document_edges(payload: dict[str, Any], ctx: EdgeContext) -> list[dict]: + """Extract party-of edges from a parsed talents/documents.json payload.""" + if not isinstance(payload, dict): + raise ValueError(f"documents payload must be a JSON object: {ctx.path}") + + parties = payload.get("parties") + if not isinstance(parties, list): + return [] + + anchor, segment_key = segment_ref(ctx.path) + ts = segment_start_ts_ms(ctx.day, segment_key) + resolved: dict[str, str] = {} + for party in parties: + if not isinstance(party, dict): + continue + name = party.get("name") + entity_id = ctx.resolve(name if isinstance(name, str) else "") + if entity_id is None: + continue + resolved.setdefault(entity_id, name.strip()) + + rows: list[dict[str, Any]] = [] + for left_id, right_id in combinations(sorted(resolved), 2): + rows.append( + { + "src": left_id, + "dst": right_id, + "kind": "party-of", + "src_name": resolved[left_id], + "dst_name": resolved[right_id], + "day": ctx.day, + "facet": ctx.facet, + "source": "document", + "path": ctx.path, + "anchor": anchor, + "label": "", + "ts": ts, + "weight": 1, + } + ) + + return rows diff --git a/solstone/think/edge_sources.py b/solstone/think/edge_sources.py index fac3073fb..80084e634 100644 --- a/solstone/think/edge_sources.py +++ b/solstone/think/edge_sources.py @@ -42,6 +42,10 @@ EDGE_SOURCES: dict[str, tuple[str, str]] = { "solstone.think.activities", "extract_activity_edges", ), + "facets/*/entities/*/observations.jsonl": ( + "solstone.think.entities.edges", + "extract_observation_edges", + ), "facets/*/entities/*.jsonl": ( "solstone.think.entities.edges", "extract_copresence_edges", @@ -50,6 +54,18 @@ EDGE_SOURCES: dict[str, tuple[str, str]] = { "solstone.think.event_formatter", "extract_event_edges", ), + "*/*/*/screen.jsonl": ( + "solstone.think.screen_edges", + "extract_screen_edges", + ), + "*/*/*/*_screen.jsonl": ( + "solstone.think.screen_edges", + "extract_screen_edges", + ), + "*/*/*/talents/documents.json": ( + "solstone.think.document_edges", + "extract_document_edges", + ), "*/*/*/talents/speaker_labels.json": ( "solstone.apps.speakers.edges", "extract_speaker_edges", diff --git a/solstone/think/entities/edges.py b/solstone/think/entities/edges.py index 26e4f21f9..f6e198a45 100644 --- a/solstone/think/entities/edges.py +++ b/solstone/think/entities/edges.py @@ -5,12 +5,70 @@ from __future__ import annotations +from datetime import datetime from itertools import combinations from typing import Any from solstone.think.edge_sources import EdgeContext +def _observation_day(value: Any) -> str | None: + if not isinstance(value, str) or len(value) != 8 or not value.isdigit(): + return None + try: + datetime.strptime(value, "%Y%m%d") + except ValueError: + return None + return value + + +def extract_observation_edges(entries: list[dict], ctx: EdgeContext) -> list[dict]: + """Extract explicit relation edges from entity observations.""" + parts = ctx.path.replace("\\", "/").split("/") + if len(parts) < 5: + raise ValueError(f"invalid observations path: {ctx.path}") + source_id = parts[3] + + rows: list[dict[str, Any]] = [] + for observation in entries: + if not isinstance(observation, dict): + continue + relation = observation.get("relation") + if relation is None: + continue + if not isinstance(relation, dict): + continue + + target_id = relation.get("target_entity_id") + if not target_id: + ctx.drop() + continue + if target_id == source_id: + continue + + observed_at = observation.get("observed_at") + anchor = str(observed_at) if observed_at is not None else None + rows.append( + { + "src": source_id, + "dst": target_id, + "kind": relation["kind"], + "src_name": None, + "dst_name": relation.get("target_name"), + "day": _observation_day(observation.get("source_day")), + "facet": ctx.facet, + "source": "observation", + "path": ctx.path, + "anchor": anchor, + "label": relation.get("note"), + "ts": observed_at, + "weight": 1, + } + ) + + return rows + + def extract_copresence_edges(entries: list[dict], ctx: EdgeContext) -> list[dict]: """Extract co-present edges from detected entity segment overlap.""" resolved: list[dict[str, Any]] = [] diff --git a/solstone/think/screen_edges.py b/solstone/think/screen_edges.py new file mode 100644 index 000000000..d8b703801 --- /dev/null +++ b/solstone/think/screen_edges.py @@ -0,0 +1,196 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Derived edge extraction from screen understanding frames. + +For ``messaged-with``, weight is the count of deduped messages in that +``(app, thread)`` group authored by either endpoint of the pair. +""" + +from __future__ import annotations + +import re +from collections import defaultdict +from datetime import datetime +from itertools import combinations +from typing import Any + +from solstone.think.edge_sources import EdgeContext, segment_ref +from solstone.think.utils import segment_start_ts_ms + + +def _string(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _event_day(start: Any) -> str | None: + if not isinstance(start, str): + return None + text = start.strip() + day: str | None = None + if re.match(r"^\d{4}-\d{2}-\d{2}", text): + day = text[:10].replace("-", "") + else: + match = re.match(r"^(\d{8})(?!\d)", text) + if match: + day = match.group(1) + + if day is None: + return None + try: + datetime.strptime(day, "%Y%m%d") + except ValueError: + return None + return day + + +def extract_screen_edges(entries: list[dict], ctx: EdgeContext) -> list[dict]: + """Extract messaging and calendar edges from screen.jsonl frames.""" + anchor, segment_key = segment_ref(ctx.path) + ts = segment_start_ts_ms(ctx.day, segment_key) + messaging_rows = _messaging_rows(entries, ctx, anchor, ts) + calendar_rows = _calendar_rows(entries, ctx, anchor, ts) + return messaging_rows + calendar_rows + + +def _messaging_rows( + entries: list[dict], + ctx: EdgeContext, + anchor: str, + ts: int, +) -> list[dict[str, Any]]: + groups: dict[tuple[str, str], dict[tuple[Any, ...], dict[str, str]]] = defaultdict( + dict + ) + for entry in entries: + if not isinstance(entry, dict): + continue + content = entry.get("content") + if not isinstance(content, dict): + continue + messaging = content.get("messaging") + if not isinstance(messaging, dict) or messaging.get("view") != "conversation": + continue + + app = _string(messaging.get("app")) + thread = _string(messaging.get("thread")) + messages = messaging.get("messages") + if not isinstance(messages, list): + continue + for message in messages: + if not isinstance(message, dict): + continue + sender = _string(message.get("sender")) + key = ( + app, + thread, + sender, + message.get("timestamp"), + _string(message.get("subject")), + _string(message.get("text")), + ) + groups[(app, thread)][key] = {"sender": sender} + + rows: list[dict[str, Any]] = [] + for (_app, thread), messages_by_key in sorted(groups.items()): + messages = list(messages_by_key.values()) + sender_ids: dict[str, str] = {} + for sender in sorted({message["sender"] for message in messages}): + entity_id = ctx.resolve(sender) + if entity_id is not None: + sender_ids[sender] = entity_id + + author_ids = [sender_ids.get(message["sender"]) for message in messages] + resolved_ids = sorted({entity_id for entity_id in author_ids if entity_id}) + for left_id, right_id in combinations(resolved_ids, 2): + endpoints = {left_id, right_id} + weight = sum(1 for entity_id in author_ids if entity_id in endpoints) + rows.append( + { + "src": left_id, + "dst": right_id, + "kind": "messaged-with", + "src_name": None, + "dst_name": None, + "day": ctx.day, + "facet": ctx.facet, + "source": "messaging", + "path": ctx.path, + "anchor": anchor, + "label": thread, + "ts": ts, + "weight": weight, + } + ) + return rows + + +def _calendar_rows( + entries: list[dict], + ctx: EdgeContext, + anchor: str, + ts: int, +) -> list[dict[str, Any]]: + events_by_key: dict[tuple[Any, ...], dict[str, Any]] = {} + for entry in entries: + if not isinstance(entry, dict): + continue + content = entry.get("content") + if not isinstance(content, dict): + continue + calendar_block = content.get("calendar") + if not isinstance(calendar_block, dict): + continue + + app = _string(calendar_block.get("app")) + events = calendar_block.get("events") + if not isinstance(events, list): + continue + for event in events: + if not isinstance(event, dict): + continue + key = ( + app, + _string(event.get("title")), + _string(event.get("start")), + _string(event.get("end")), + _string(event.get("calendar")), + ) + events_by_key[key] = event + + rows: list[dict[str, Any]] = [] + for event in events_by_key.values(): + guests = event.get("guests") + if not isinstance(guests, list): + continue + + resolved_ids: set[str] = set() + for guest in guests: + entity_id = ctx.resolve(_string(guest)) + if entity_id is not None: + resolved_ids.add(entity_id) + if len(resolved_ids) < 2: + continue + + day = _event_day(event.get("start")) or ctx.day + # Edge days must always be parseable; malformed segment days reach here. + datetime.strptime(day, "%Y%m%d") + for left_id, right_id in combinations(sorted(resolved_ids), 2): + rows.append( + { + "src": left_id, + "dst": right_id, + "kind": "scheduled-with", + "src_name": None, + "dst_name": None, + "day": day, + "facet": ctx.facet, + "source": "calendar", + "path": ctx.path, + "anchor": anchor, + "label": _string(event.get("title")), + "ts": ts, + "weight": 1, + } + ) + return rows diff --git a/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/left_screen.jsonl b/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/left_screen.jsonl new file mode 100644 index 000000000..fc0bff9a6 --- /dev/null +++ b/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/left_screen.jsonl @@ -0,0 +1,2 @@ +{"raw":"left_screen.png","model":"fixture"} +{"timestamp":0,"content":{"messaging":{"view":"conversation","app":"Slack","thread":"Doc Review","messages":[{"sender":"Mina Edge","timestamp":"2026-04-30T09:02:00Z","subject":"","text":"Ravi, can you review the document edge?"},{"sender":"Ravi Edge","timestamp":"2026-04-30T09:02:30Z","subject":"","text":"Yes, I am on it."}]}}} diff --git a/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/screen.jsonl b/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/screen.jsonl new file mode 100644 index 000000000..50a6eb21f --- /dev/null +++ b/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/screen.jsonl @@ -0,0 +1,3 @@ +{"raw":"screen.png","model":"fixture"} +{"timestamp":0,"content":{"messaging":{"view":"conversation","app":"Signal","thread":"Edge Planning","messages":[{"sender":"Alice Edge","timestamp":"2026-04-30T09:00:01Z","subject":"","text":"Can we review the edge plan?"},{"sender":"Bob Edge","timestamp":"2026-04-30T09:00:10Z","subject":"","text":"I will check the relation rows."},{"sender":"Cora Edge","timestamp":"2026-04-30T09:00:20Z","subject":"","text":"I can validate calendar rows."}]},"calendar":{"view":"day","app":"Calendar","events":[{"title":"Future Edge Review","start":"2026-05-01T09:30:00-06:00","end":"2026-05-01T10:00:00-06:00","calendar":"Work","guests":["Ada Edge","Byron Edge","Cyra Edge"]}]}}} +{"timestamp":1500,"content":{"messaging":{"view":"conversation","app":"Signal","thread":"Edge Planning","messages":[{"sender":"Alice Edge","timestamp":"2026-04-30T09:00:01Z","subject":"","text":"Can we review the edge plan?"},{"sender":"Bob Edge","timestamp":"2026-04-30T09:00:10Z","subject":"","text":"I will check the relation rows."},{"sender":"Cora Edge","timestamp":"2026-04-30T09:00:20Z","subject":"","text":"I can validate calendar rows."}]},"calendar":{"view":"day","app":"Calendar","events":[{"title":"Future Edge Review","start":"2026-05-01T09:30:00-06:00","end":"2026-05-01T10:00:00-06:00","calendar":"Work","guests":["Ada Edge","Byron Edge","Cyra Edge"]}]}}} diff --git a/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/talents/documents.json b/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/talents/documents.json new file mode 100644 index 000000000..13edb5695 --- /dev/null +++ b/tests/fixtures/edges_journal/chronicle/20260430/default/090000_300/talents/documents.json @@ -0,0 +1,34 @@ +{ + "overview": "Fixture document used to derive party-of edges.", + "parties": [ + { + "name": "Mina Edge", + "role": "author", + "formal_term": "Author", + "context": "Drafts the agreement." + }, + { + "name": "Ravi Edge", + "role": "reviewer", + "formal_term": "Reviewer", + "context": "Reviews the agreement." + }, + { + "name": "Tessa Edge", + "role": "approver", + "formal_term": "Approver", + "context": "Approves the agreement." + }, + { + "name": "Unknown Edge", + "role": "observer", + "formal_term": "Observer", + "context": "Does not resolve to a journal entity." + } + ], + "key_provisions": [], + "assets": [], + "conditions": [], + "important_dates": [], + "summary": "Mina, Ravi, and Tessa are co-parties." +} diff --git a/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_mina/entity.json b/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_mina/entity.json new file mode 100644 index 000000000..1098813f3 --- /dev/null +++ b/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_mina/entity.json @@ -0,0 +1 @@ +{"entity_id":"edge_mina","description":"Attached observation source."} diff --git a/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_mina/observations.jsonl b/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_mina/observations.jsonl new file mode 100644 index 000000000..3152e6e22 --- /dev/null +++ b/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_mina/observations.jsonl @@ -0,0 +1,3 @@ +{"content":"Mina works with Ravi on edge planning.","observed_at":1777556000000,"source_day":"20260430","relation":{"kind":"works-with","target_entity_id":"edge_ravi","target_name":"Ravi Edge","note":"Plans edge enrichment together"}} +{"content":"Mina has an unresolved relation target.","observed_at":1777556100000,"source_day":"20260430","relation":{"kind":"knows","target_entity_id":null,"target_name":"Unknown Edge","note":"Missing target should drop"}} +{"content":"Mina noted a non-relation observation for search.","observed_at":1777556200000,"source_day":"20260430"} diff --git a/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_ravi/entity.json b/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_ravi/entity.json new file mode 100644 index 000000000..05239eb0b --- /dev/null +++ b/tests/fixtures/edges_journal/facets/edges-observations/entities/edge_ravi/entity.json @@ -0,0 +1 @@ +{"entity_id":"edge_ravi","description":"Attached observation target."} diff --git a/tests/fixtures/edges_journal/facets/edges-observations/facet.json b/tests/fixtures/edges_journal/facets/edges-observations/facet.json new file mode 100644 index 000000000..3b9d6a3d7 --- /dev/null +++ b/tests/fixtures/edges_journal/facets/edges-observations/facet.json @@ -0,0 +1 @@ +{"id":"edges-observations","name":"Edges Observations"} diff --git a/tests/fixtures/edges_journal/facets/edges-story/activities/20260430.jsonl b/tests/fixtures/edges_journal/facets/edges-story/activities/20260430.jsonl index b7a00f819..0ff54f102 100644 --- a/tests/fixtures/edges_journal/facets/edges-story/activities/20260430.jsonl +++ b/tests/fixtures/edges_journal/facets/edges-story/activities/20260430.jsonl @@ -1 +1 @@ -{"id":"story-commitments-1","title":"Edge Story Session","created_at":1777554000000,"commitments":[{"owner":"Mina Edge","action":"Send the proposal","counterparty":"Ravi Edge","owner_entity_id":"edge_mina","counterparty_entity_id":"edge_ravi"},{"owner":"Mina Edge","action":"Find a counterparty","counterparty":"Unknown","owner_entity_id":"edge_mina","counterparty_entity_id":null}],"closures":[{"owner":"Tessa Edge","action":"Confirm the handoff","counterparty":"Mina Edge","owner_entity_id":"edge_tessa","counterparty_entity_id":"edge_mina","resolution":"done"}],"decisions":[{"owner":"Mina Edge","action":"Use the stable plan","context":"Owner-only decision","owner_entity_id":"edge_mina"}]} +{"id":"story-commitments-1","title":"Edge Story Session","created_at":1777554000000,"commitments":[{"owner":"Mina Edge","action":"Send the proposal","counterparty":"Ravi Edge","owner_entity_id":"edge_mina","counterparty_entity_id":"edge_ravi"},{"owner":"Mina Edge","action":"Find a counterparty","counterparty":"Unknown","owner_entity_id":"edge_mina","counterparty_entity_id":null}],"closures":[{"owner":"Tessa Edge","action":"Confirm the handoff","counterparty":"Mina Edge","owner_entity_id":"edge_tessa","counterparty_entity_id":"edge_mina","resolution":"done"}],"relations":[{"from":"Mina Edge","to":"Ravi Edge","from_entity_id":"edge_mina","to_entity_id":"edge_ravi","kind":"works-with","note":"Runs planning together","quote":"Let's pair on this"},{"from":"Mina Edge","to":"Tessa Edge","from_entity_id":"edge_mina","to_entity_id":"edge_tessa","kind":"reports-to","note":"Reports project status","quote":null}],"decisions":[{"owner":"Mina Edge","action":"Use the stable plan","context":"Owner-only decision","owner_entity_id":"edge_mina"},{"owner":"Mina Edge","counterparty":"Ravi Edge","action":"Use the stable plan together","context":"Resolved decision","owner_entity_id":"edge_mina","counterparty_entity_id":"edge_ravi"}]} diff --git a/tests/test_edges_query.py b/tests/test_edges_query.py index 91b8b9620..0f86e9f20 100644 --- a/tests/test_edges_query.py +++ b/tests/test_edges_query.py @@ -364,6 +364,94 @@ def test_entity_network_ranks_by_weighted_decayed_score(edges_journal): assert network["neighbors"][1]["score"] == pytest.approx(2.0) +def test_future_scheduled_edges_score_like_reference_day(edges_journal): + scan_journal(str(edges_journal), full=True) + _insert( + edges_journal, + [ + _row( + "edge_future_self", + "edge_future_peer", + "scheduled-with", + "future/scheduled.jsonl", + day="20260601", + weight=1, + ), + _row( + "edge_future_self", + "edge_reference_peer", + "scheduled-with", + "future/reference.jsonl", + day="20260430", + weight=1, + ), + ], + ) + + network = load_entity_network( + "edge_future_self", + kinds=["scheduled-with"], + reference_day="20260430", + ) + by_peer = {row["entity_id"]: row for row in network["neighbors"]} + + assert by_peer["edge_future_peer"]["score"] == pytest.approx( + by_peer["edge_reference_peer"]["score"] + ) + assert by_peer["edge_future_peer"]["score"] == pytest.approx(2.0) + + +def test_entity_network_ranking_for_semantic_scheduled_and_copresent_kinds( + edges_journal, +): + scan_journal(str(edges_journal), full=True) + _insert( + edges_journal, + [ + _row( + "edge_rank_sem_self", + "edge_rank_durable", + "works-with", + "rank-sem/durable.jsonl", + day="20260430", + weight=1, + ), + _row( + "edge_rank_sem_self", + "edge_rank_scheduled", + "scheduled-with", + "rank-sem/scheduled.jsonl", + day="20260430", + weight=1, + ), + _row( + "edge_rank_sem_self", + "edge_rank_passive", + "co-present", + "rank-sem/passive.jsonl", + day="20260430", + weight=1, + ), + ], + ) + + network = load_entity_network("edge_rank_sem_self", reference_day="20260430") + + assert [row["entity_id"] for row in network["neighbors"]] == [ + "edge_rank_durable", + "edge_rank_scheduled", + "edge_rank_passive", + ] + by_peer = {row["entity_id"]: row for row in network["neighbors"]} + assert by_peer["edge_rank_durable"]["score"] == pytest.approx(4.0) + assert by_peer["edge_rank_scheduled"]["score"] == pytest.approx(2.0) + assert by_peer["edge_rank_passive"]["score"] == pytest.approx(1.0) + assert by_peer["edge_rank_passive"]["kinds"]["co-present"] == { + "count": 1, + "weighted": 1.0, + } + + def test_edge_evidence_total_pagination_and_stable_newest_order(edges_journal): scan_journal(str(edges_journal), full=True) _insert( diff --git a/tests/test_index_edges.py b/tests/test_index_edges.py index 53c4b1406..f6007e026 100644 --- a/tests/test_index_edges.py +++ b/tests/test_index_edges.py @@ -16,11 +16,16 @@ from typing import Any import pytest from solstone.think import edge_sources +from solstone.think.activities import ( + _relation_label, + extract_activity_edges, +) from solstone.think.edge_sources import EdgeContext, get_edge_source from solstone.think.formatters import discover_files from solstone.think.indexer.edges import ( EDGES_SCHEMA_PATH, _extract_file_edges, + discover_edge_files, insert_edges, rebuild_edges, ) @@ -98,6 +103,31 @@ def bad_kind_edge_extractor(entries: list[dict], ctx: EdgeContext) -> list[dict] ] +def day_rooted_resolving_edge_extractor( + entries: list[dict], + ctx: EdgeContext, +) -> list[dict]: + assert entries == [{"ok": True}] + src = ctx.resolve("Ada Edge") + assert src == "edge_ada" + ctx.drop() + return [ + { + "src": src, + "dst": "edge_byron", + "kind": "attended-with", + "source": "participation", + "path": ctx.path, + "anchor": "day-rooted", + "label": "", + "day": ctx.day, + "facet": ctx.facet, + "ts": 0, + "weight": 1, + } + ] + + def _conn(journal: Path) -> sqlite3.Connection: conn, _ = get_journal_index(str(journal)) conn.row_factory = sqlite3.Row @@ -128,6 +158,10 @@ def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: ) +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + def _read_jsonl(path: Path) -> list[dict[str, Any]]: return [ json.loads(line) @@ -141,6 +175,216 @@ def _bump_mtime(path: Path) -> None: os.utime(path, (bumped, bumped)) +def _edge_ctx(rel: str, *, facet: str = "edges-story") -> EdgeContext: + return EdgeContext( + path=rel, + day="20260430", + facet=facet, + resolve=lambda _name: None, + drop=lambda: None, + ) + + +def test_relation_label_formats_documented_forms(): + assert _relation_label("Works together", None) == "Works together" + assert _relation_label("", "quoted text") == '"quoted text"' + assert ( + _relation_label("Works together", "quoted text") + == 'Works together — "quoted text"' + ) + assert _relation_label(" ", None) == "" + + +def test_activity_story_generalization_preserves_commitment_and_closure_rows_byte_exact( + edges_journal, +): + rel = "facets/edges-story/activities/20260430.jsonl" + rows = extract_activity_edges(_read_jsonl(edges_journal / rel), _edge_ctx(rel)) + + story_rows = [row for row in rows if row["source"] in {"commitment", "closure"}] + assert story_rows == [ + { + "src": "edge_mina", + "dst": "edge_ravi", + "kind": "committed-to", + "src_name": None, + "dst_name": None, + "day": "20260430", + "facet": "edges-story", + "source": "commitment", + "path": rel, + "anchor": "story-commitments-1", + "label": "Send the proposal", + "ts": 1777554000000, + "weight": 1, + }, + { + "src": "edge_tessa", + "dst": "edge_mina", + "kind": "committed-to", + "src_name": None, + "dst_name": None, + "day": "20260430", + "facet": "edges-story", + "source": "closure", + "path": rel, + "anchor": "story-commitments-1", + "label": "Confirm the handoff", + "ts": 1777554000000, + "weight": 1, + }, + ] + + +def test_activity_relations_and_decisions_emit_expected_rows(edges_journal): + rel = "facets/edges-story/activities/20260430.jsonl" + record = { + "id": "story-relations-1", + "title": "Relation session", + "created_at": 1777555000000, + "relations": [ + { + "from": "Mina Edge", + "to": "Ravi Edge", + "from_entity_id": "edge_mina", + "to_entity_id": "edge_ravi", + "kind": "works-with", + "note": "Runs planning together", + "quote": "Let's pair on this", + }, + { + "from": "Tessa Edge", + "to": "Tessa Edge", + "from_entity_id": "edge_tessa", + "to_entity_id": "edge_tessa", + "kind": "knows", + "note": "self", + "quote": None, + }, + ], + "decisions": [ + { + "owner": "Mina Edge", + "counterparty": "Ravi Edge", + "owner_entity_id": "edge_mina", + "counterparty_entity_id": "edge_ravi", + "action": "Use the stable plan", + }, + { + "owner": "Mina Edge", + "counterparty": "Mina Edge", + "owner_entity_id": "edge_mina", + "counterparty_entity_id": "edge_mina", + "action": "Skip self", + }, + ], + } + + rows = extract_activity_edges([record], _edge_ctx(rel)) + + assert rows == [ + { + "src": "edge_mina", + "dst": "edge_ravi", + "kind": "decided-with", + "src_name": None, + "dst_name": None, + "day": "20260430", + "facet": "edges-story", + "source": "decision", + "path": rel, + "anchor": "story-relations-1", + "label": "Use the stable plan", + "ts": 1777555000000, + "weight": 1, + }, + { + "src": "edge_mina", + "dst": "edge_ravi", + "kind": "works-with", + "src_name": "Mina Edge", + "dst_name": "Ravi Edge", + "day": "20260430", + "facet": "edges-story", + "source": "relation", + "path": rel, + "anchor": "story-relations-1", + "label": 'Runs planning together — "Let\'s pair on this"', + "ts": 1777555000000, + "weight": 1, + }, + ] + + +def test_activity_unknown_relation_kind_raises_at_insert(edges_journal): + rel = "facets/edges-story/activities/20260430.jsonl" + rows = extract_activity_edges( + [ + { + "id": "story-bad-relation-kind", + "created_at": 1777555000000, + "relations": [ + { + "from": "Mina Edge", + "to": "Ravi Edge", + "from_entity_id": "edge_mina", + "to_entity_id": "edge_ravi", + "kind": "unknown-relation-kind", + "note": "Bad kind", + "quote": None, + } + ], + } + ], + _edge_ctx(rel), + ) + + conn = _conn(edges_journal) + with pytest.raises(ValueError, match="Unknown edge kind"): + insert_edges(conn, rows) + conn.close() + + +def test_day_rooted_context_resolves_journal_entities_and_drop_hook_counts( + edges_journal, + monkeypatch, +): + monkeypatch.setitem( + edge_sources.EDGE_SOURCES, + "*/*/*/synthetic.jsonl", + ("tests.test_index_edges", "day_rooted_resolving_edge_extractor"), + ) + rel = "20260430/default/090000_300/synthetic.jsonl" + path = edges_journal / "chronicle" / rel + path.parent.mkdir(parents=True, exist_ok=True) + _write_jsonl(path, [{"ok": True}]) + + conn = _conn(edges_journal) + result = _extract_file_edges(conn, rel, str(path), {}) + + assert result.rows_inserted == 1 + assert result.drops == 1 + assert not result.failed + assert ( + conn.execute("SELECT src FROM edges WHERE path=?", (rel,)).fetchone()[0] + == "edge_ada" + ) + conn.close() + + +def test_discover_edge_files_keeps_structural_sources_with_chronicle_root( + edges_journal, +): + files = discover_edge_files(str(edges_journal)) + + assert "facets/edges-activity/activities/20260430.jsonl" in files + assert "facets/edges-story/activities/20260430.jsonl" in files + assert "facets/edges-copresence/entities/20260430.jsonl" in files + assert "facets/edges-events/events/20260430.jsonl" in files + assert "20260430/default/090000_300/screen.jsonl" in files + assert "20260430/default/090000_300/talents/documents.json" in files + + def test_scan_indexes_edges_and_second_scan_is_zero_delta(edges_journal): assert scan_journal(str(edges_journal), full=True) is True @@ -149,13 +393,19 @@ def test_scan_indexes_edges_and_second_scan_is_zero_delta(edges_journal): "SELECT mtime FROM edge_files WHERE path=?", (EDGES_SCHEMA_PATH,) ).fetchone()[0] assert schema_version == 1 - assert conn.execute("SELECT count(*) FROM edges").fetchone()[0] == 10 + assert conn.execute("SELECT count(*) FROM edges").fetchone()[0] == 24 assert _source_counts(conn) == { + "calendar": 3, "co-presence": 2, "closure": 1, "commitment": 1, + "decision": 1, + "document": 3, "event-legacy": 3, + "messaging": 4, + "observation": 1, "participation": 3, + "relation": 2, } participation = _edge_rows(conn, "source='participation'") @@ -191,6 +441,18 @@ def test_scan_indexes_edges_and_second_scan_is_zero_delta(edges_journal): assert closure[0]["src_name"] is None assert closure[0]["dst_name"] is None + relation = {row["kind"]: row for row in _edge_rows(conn, "source='relation'")} + assert set(relation) == {"reports-to", "works-with"} + assert relation["works-with"]["label"] == ( + 'Runs planning together — "Let\'s pair on this"' + ) + assert relation["works-with"]["directed"] == 0 + assert relation["reports-to"]["directed"] == 1 + decision = _edge_rows(conn, "source='decision'") + assert len(decision) == 1 + assert decision[0]["kind"] == "decided-with" + assert decision[0]["label"] == "Use the stable plan together" + copresence = { (row["src"], row["dst"]): row for row in _edge_rows(conn, "source='co-presence'") @@ -273,23 +535,340 @@ def test_deleted_edge_source_removes_rows_and_ledger(edges_journal): conn.close() -def test_observations_file_has_no_copresence_edge_source(edges_journal): - rel = "facets/edges-copresence/entities/edge_alice/observations.jsonl" - assert get_edge_source("facets/work/entities/acme/observations.jsonl") is None +def test_observation_relations_emit_edges_and_non_relations_still_index( + edges_journal, +): + rel = "facets/edges-observations/entities/edge_mina/observations.jsonl" + extractor = get_edge_source("facets/work/entities/acme/observations.jsonl") + assert extractor is not None + assert extractor.__name__ == "extract_observation_edges" + + conn = _conn(edges_journal) + result = _extract_file_edges(conn, rel, str(edges_journal / rel), {}) + assert result.rows_inserted == 1 + assert result.drops == 1 + row = conn.execute( + """ + SELECT src, dst, kind, day, facet, source, anchor, label + FROM edges + WHERE path=? + """, + (rel,), + ).fetchone() + assert dict(row) == { + "src": "edge_mina", + "dst": "edge_ravi", + "kind": "works-with", + "day": "20260430", + "facet": "edges-observations", + "source": "observation", + "anchor": "1777556000000", + "label": "Plans edge enrichment together", + } + conn.close() + + assert index_file(str(edges_journal), rel) is True + + conn = _conn(edges_journal) + assert ( + conn.execute("SELECT count(*) FROM chunks WHERE path=?", (rel,)).fetchone()[0] + > 0 + ) + assert ( + conn.execute("SELECT count(*) FROM edges WHERE path=?", (rel,)).fetchone()[0] + == 1 + ) + assert ( + conn.execute( + "SELECT count(*) FROM chunks WHERE path=? AND chunks MATCH ?", + (rel, '"non relation observation"'), + ).fetchone()[0] + > 0 + ) + conn.close() + + +def test_screen_sources_emit_messaging_calendar_and_event_day(edges_journal): + rel = "20260430/default/090000_300/screen.jsonl" + named_rel = "20260430/default/090000_300/left_screen.jsonl" - path = edges_journal / rel - _write_jsonl(path, [{"content": "Observation-only content", "observed_at": 1}]) assert index_file(str(edges_journal), rel) is True + assert index_file(str(edges_journal), named_rel) is True + + conn = _conn(edges_journal) + screen_rows = _edge_rows(conn, f"path='{rel}'") + named_rows = _edge_rows(conn, f"path='{named_rel}'") + conn.close() + + messaging = [row for row in screen_rows if row["source"] == "messaging"] + calendar = [row for row in screen_rows if row["source"] == "calendar"] + assert len(messaging) == 3 + assert {row["kind"] for row in messaging} == {"messaged-with"} + assert {row["label"] for row in messaging} == {"Edge Planning"} + assert {row["weight"] for row in messaging} == {2} + + assert len(calendar) == 3 + assert {row["kind"] for row in calendar} == {"scheduled-with"} + assert {row["day"] for row in calendar} == {"20260501"} + assert {row["label"] for row in calendar} == {"Future Edge Review"} + + assert len(named_rows) == 1 + assert named_rows[0]["kind"] == "messaged-with" + assert named_rows[0]["source"] == "messaging" + assert named_rows[0]["label"] == "Doc Review" + assert named_rows[0]["weight"] == 2 + + +def test_pretty_documents_json_edges_via_index_file_and_scan_journal(edges_journal): + rel = "20260430/default/090000_300/talents/documents.json" conn = _conn(edges_journal) + result = _extract_file_edges(conn, rel, str(edges_journal / "chronicle" / rel), {}) + assert result.rows_inserted == 3 + assert result.drops == 1 + conn.close() + + assert index_file(str(edges_journal), rel) is True + conn = _conn(edges_journal) + assert ( + conn.execute( + "SELECT count(*) FROM edges WHERE path=? AND source='document'", + (rel,), + ).fetchone()[0] + == 3 + ) assert ( conn.execute("SELECT count(*) FROM chunks WHERE path=?", (rel,)).fetchone()[0] > 0 ) + conn.close() + + assert scan_journal(str(edges_journal), full=True) is True + conn = _conn(edges_journal) + assert ( + conn.execute( + "SELECT count(*) FROM edges WHERE path=? AND source='document'", + (rel,), + ).fetchone()[0] + == 3 + ) + conn.close() + + +def _replace_new_source_fixture(shape: str, path: Path) -> None: + if shape == "observations": + _write_jsonl( + path, + [ + { + "content": "Mina works with Ravi after replacement.", + "observed_at": 1777557000000, + "source_day": "20260430", + "relation": { + "kind": "works-with", + "target_entity_id": "edge_ravi", + "target_name": "Ravi Edge", + "note": "Replacement relation one", + }, + }, + { + "content": "Mina knows Tessa after replacement.", + "observed_at": 1777557100000, + "source_day": "20260430", + "relation": { + "kind": "knows", + "target_entity_id": "edge_tessa", + "target_name": "Tessa Edge", + "note": "Replacement relation two", + }, + }, + ], + ) + return + + if shape == "screen": + _write_jsonl( + path, + [ + {"raw": "screen.png", "model": "fixture"}, + { + "timestamp": 0, + "content": { + "messaging": { + "view": "conversation", + "app": "Signal", + "thread": "Replacement Thread", + "messages": [ + { + "sender": "Alice Edge", + "timestamp": "2026-04-30T09:10:00Z", + "subject": "", + "text": "Replacement message", + }, + { + "sender": "Bob Edge", + "timestamp": "2026-04-30T09:10:30Z", + "subject": "", + "text": "Replacement reply", + }, + ], + } + }, + }, + ], + ) + return + + if shape == "named_screen": + _write_jsonl( + path, + [ + {"raw": "left_screen.png", "model": "fixture"}, + { + "timestamp": 0, + "content": { + "calendar": { + "view": "week", + "app": "Calendar", + "events": [ + { + "title": "Replacement Calendar", + "start": "20260430T093000", + "end": "20260430T100000", + "calendar": "Work", + "guests": [ + "Mina Edge", + "Ravi Edge", + "Tessa Edge", + ], + } + ], + } + }, + }, + ], + ) + return + + if shape == "documents": + _write_json( + path, + { + "overview": "Replacement document.", + "parties": [ + {"name": "Mina Edge", "role": "author"}, + {"name": "Ravi Edge", "role": "reviewer"}, + ], + "key_provisions": [], + "assets": [], + "conditions": [], + "important_dates": [], + "summary": "Replacement parties.", + }, + ) + return + + raise AssertionError(f"unknown source fixture shape: {shape}") + + +@pytest.mark.parametrize( + ("shape", "rel", "initial_rows", "replacement_rows"), + [ + ( + "observations", + "facets/edges-observations/entities/edge_mina/observations.jsonl", + 1, + 2, + ), + ("screen", "20260430/default/090000_300/screen.jsonl", 6, 1), + ("named_screen", "20260430/default/090000_300/left_screen.jsonl", 1, 3), + ( + "documents", + "20260430/default/090000_300/talents/documents.json", + 3, + 1, + ), + ], +) +def test_new_source_shapes_support_index_file_replacement_and_deletion( + edges_journal, + shape, + rel, + initial_rows, + replacement_rows, +): + assert index_file(str(edges_journal), rel) is True + conn = _conn(edges_journal) + assert ( + conn.execute("SELECT count(*) FROM edges WHERE path=?", (rel,)).fetchone()[0] + == initial_rows + ) + conn.close() + + path = edges_journal / ("chronicle" if rel.startswith("202") else "") / rel + _replace_new_source_fixture(shape, path) + _bump_mtime(path) + + assert scan_journal(str(edges_journal), full=True) is True + conn = _conn(edges_journal) + assert ( + conn.execute("SELECT count(*) FROM edges WHERE path=?", (rel,)).fetchone()[0] + == replacement_rows + ) + conn.close() + + path.unlink() + assert scan_journal(str(edges_journal), full=True) is True + conn = _conn(edges_journal) assert ( conn.execute("SELECT count(*) FROM edges WHERE path=?", (rel,)).fetchone()[0] == 0 ) + assert ( + conn.execute("SELECT 1 FROM edge_files WHERE path=?", (rel,)).fetchone() is None + ) + conn.close() + + +def test_malformed_new_source_fails_without_suppressing_sibling( + edges_journal, + caplog, +): + bad_rel = "20260430/default/090000_300/talents/documents.json" + bad_path = edges_journal / "chronicle" / bad_rel + bad_path.write_text("{\n", encoding="utf-8") + + caplog.set_level(logging.ERROR, logger="solstone.think.indexer.edges") + conn = _conn(edges_journal) + result = _extract_file_edges(conn, bad_rel, str(bad_path), {}) + assert result.failed + assert result.rows_inserted == 0 + conn.close() + assert f"Skipping edge extraction for {bad_rel}" in caplog.text + + caplog.clear() + caplog.set_level(logging.ERROR, logger="solstone.think.indexer.edges") + assert scan_journal(str(edges_journal), full=True) is True + assert f"Skipping edge extraction for {bad_rel}" in caplog.text + + conn = _conn(edges_journal) + assert ( + conn.execute("SELECT count(*) FROM edges WHERE path=?", (bad_rel,)).fetchone()[ + 0 + ] + == 0 + ) + assert ( + conn.execute( + "SELECT count(*) FROM edges WHERE path=?", + ("20260430/default/090000_300/screen.jsonl",), + ).fetchone()[0] + == 6 + ) + assert ( + conn.execute("SELECT 1 FROM edge_files WHERE path=?", (bad_rel,)).fetchone() + is not None + ) conn.close() @@ -385,6 +964,13 @@ def test_rebuild_edges_is_idempotent_and_preserves_chunks_files(edges_journal): conn = _conn(edges_journal) chunks_hash = table_content_hash(conn, "chunks", CHUNK_COLUMNS) files_hash = table_content_hash(conn, "files", FILE_COLUMNS) + assert ( + conn.execute( + "SELECT count(*) FROM chunks WHERE path=?", + ("20260430/default/090000_300/talents/documents.json",), + ).fetchone()[0] + > 0 + ) conn.close() first = rebuild_edges(str(edges_journal)) @@ -401,10 +987,10 @@ def test_rebuild_edges_is_idempotent_and_preserves_chunks_files(edges_journal): assert table_content_hash(conn, "files", FILE_COLUMNS) == files_hash conn.close() - assert first["rows"] == 10 - assert second["rows"] == 10 - assert first["drops"] == 1 - assert second["drops"] == 1 + assert first["rows"] == 24 + assert second["rows"] == 24 + assert first["drops"] == 3 + assert second["drops"] == 3 def test_extract_file_edges_counts_only_resolution_drops(edges_journal): @@ -483,7 +1069,7 @@ def test_schema_version_migration_preserves_chunks_and_files( conn = _conn(edges_journal) chunks_hash = table_content_hash(conn, "chunks", CHUNK_COLUMNS) files_hash = table_content_hash(conn, "files", FILE_COLUMNS) - assert conn.execute("SELECT count(*) FROM edges").fetchone()[0] == 10 + assert conn.execute("SELECT count(*) FROM edges").fetchone()[0] == 24 conn.close() monkeypatch.setattr(edge_index, "EDGES_SCHEMA_VERSION", 2) @@ -501,7 +1087,7 @@ def test_schema_version_migration_preserves_chunks_and_files( assert scan_journal(str(edges_journal), full=True) is True conn = _conn(edges_journal) - assert conn.execute("SELECT count(*) FROM edges").fetchone()[0] == 10 + assert conn.execute("SELECT count(*) FROM edges").fetchone()[0] == 24 conn.close()