kenning-continuity 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. kenning_continuity/__init__.py +34 -0
  2. kenning_continuity/_cypher/_resolve.md +16 -0
  3. kenning_continuity/_cypher/chain_history.cypher +14 -0
  4. kenning_continuity/_cypher/domain_merge.cypher +5 -0
  5. kenning_continuity/_cypher/domain_state.cypher +38 -0
  6. kenning_continuity/_cypher/index_create.cypher +3 -0
  7. kenning_continuity/_cypher/knowledge_collision.cypher +14 -0
  8. kenning_continuity/_cypher/knowledge_confirm.cypher +9 -0
  9. kenning_continuity/_cypher/knowledge_create.cypher +18 -0
  10. kenning_continuity/_cypher/knowledge_evolve.cypher +20 -0
  11. kenning_continuity/_cypher/referent_claim.cypher +24 -0
  12. kenning_continuity/_cypher/search_connections.cypher +32 -0
  13. kenning_continuity/_cypher/search_knowledge.cypher +11 -0
  14. kenning_continuity/_cypher/session_create.cypher +21 -0
  15. kenning_continuity/_cypher/session_history.cypher +15 -0
  16. kenning_continuity/_migration/__init__.py +1 -0
  17. kenning_continuity/_migration/knowledge_focus.py +259 -0
  18. kenning_continuity/_migration/person_layer.py +517 -0
  19. kenning_continuity/server.py +816 -0
  20. kenning_continuity/substrate.py +1580 -0
  21. kenning_continuity/utils.py +360 -0
  22. kenning_continuity-0.1.0.dist-info/METADATA +343 -0
  23. kenning_continuity-0.1.0.dist-info/RECORD +28 -0
  24. kenning_continuity-0.1.0.dist-info/WHEEL +4 -0
  25. kenning_continuity-0.1.0.dist-info/entry_points.txt +2 -0
  26. kenning_continuity-0.1.0.dist-info/licenses/LICENSE +202 -0
  27. kenning_continuity-0.1.0.dist-info/licenses/NOTICE +14 -0
  28. kenning_continuity-0.1.0.dist-info/licenses/TRADEMARKS.md +88 -0
@@ -0,0 +1,34 @@
1
+ from . import server
2
+ import asyncio
3
+ import argparse
4
+ import logging
5
+
6
+ from .utils import process_config
7
+
8
+ logger = logging.getLogger("kenning_continuity")
9
+ logger.setLevel(logging.INFO)
10
+
11
+ def main():
12
+ """Main entry point for the package."""
13
+ parser = argparse.ArgumentParser(description='Temporal Knowledge Substrate MCP Server')
14
+ parser.add_argument('--db-url', default=None, help='Neo4j connection URL')
15
+ parser.add_argument('--username', default=None, help='Neo4j username')
16
+ parser.add_argument('--password', default=None, help='Neo4j password')
17
+ parser.add_argument("--database", default=None, help="Neo4j database name")
18
+ parser.add_argument("--namespace", default=None, help="Tool namespace prefix")
19
+ parser.add_argument("--transport", default=None, help="Transport type (stdio, sse, http)")
20
+ parser.add_argument("--server-host", default=None, help="HTTP host (default: 127.0.0.1)")
21
+ parser.add_argument("--server-port", type=int, default=None, help="HTTP port (default: 8000)")
22
+ parser.add_argument("--server-path", default=None, help="HTTP path (default: /mcp/)")
23
+ parser.add_argument("--allow-origins", default=None, help="Comma-separated list of allowed CORS origins")
24
+ parser.add_argument("--allowed-hosts", default=None, help="Comma-separated list of allowed hosts for DNS rebinding protection")
25
+ parser.add_argument("--read-timeout", type=int, default=None, help="Read query timeout in seconds (default: 30)")
26
+
27
+ args = parser.parse_args()
28
+
29
+ config = process_config(args)
30
+ asyncio.run(server.main(**config))
31
+
32
+
33
+ # Optionally expose other important items at package level
34
+ __all__ = ["main", "server"]
@@ -0,0 +1,16 @@
1
+ The canonical resolution of a knowledge entity by (name, domain).
2
+
3
+ MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)
4
+ -[:DISCOVERED]->(e:Knowledge {name: $name})
5
+ WHERE NOT (e)<-[:EVOLVED_FROM]-()
6
+
7
+ `domain` is a query parameter, never a stored property. A knowledge entity is
8
+ downstream of exactly one Domain by construction — every entity is discovered by
9
+ at least one session (`@card(1..)` on `discovered:found`) and every session
10
+ belongs to exactly one domain (`@card(1..1)` on `has-session:held`) — so the
11
+ walk is total and unambiguous. Measured on the production graph: 3,101 of 3,101
12
+ resolve to exactly one domain, none to zero and none to more.
13
+
14
+ The property it replaces was a denormalization and had already drifted: 21
15
+ entities carried a `domain` string naming a real Domain that had no path to them
16
+ at all. A fact held as text beside a structure rather than by it.
@@ -0,0 +1,14 @@
1
+ MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)
2
+ -[:DISCOVERED]->(head:Knowledge {name: $name})
3
+ WHERE NOT (head)<-[:EVOLVED_FROM]-()
4
+ WITH DISTINCT head
5
+ MATCH path = (head)-[:EVOLVED_FROM*0..]->(n)
6
+ WITH n, length(path) AS depth
7
+ RETURN n.name AS name,
8
+ n.ont_type AS type,
9
+ labels(n) AS labels,
10
+ $domain AS domain,
11
+ n.description AS description,
12
+ n.t_created AS t_created,
13
+ depth
14
+ ORDER BY n.t_created ASC
@@ -0,0 +1,5 @@
1
+ MERGE (d:Domain {name: $name})
2
+ ON CREATE SET d.t_created = datetime(),
3
+ d.description = $description
4
+ RETURN d.name AS name, d.description AS description,
5
+ d.t_created AS t_created
@@ -0,0 +1,38 @@
1
+ // Current knowledge for a domain: head-of-chain entities, with their edges
2
+ // resolved THROUGH the chain.
3
+ //
4
+ // An edge is authored on whichever node was head at the time. Reading edges
5
+ // only between two head-of-chain nodes therefore strands every connection the
6
+ // moment either endpoint evolves — not deleted, unreachable. Measured live:
7
+ // 3,678 visible against 4,425 authored, so 737 were unreachable, and the
8
+ // stranding is symmetric (either endpoint being evolved is enough).
9
+ //
10
+ // `types` and `limit` bound the payload. begin_session used to return this
11
+ // whole thing inline: 498 heads and 600,820 characters on ServiceNow Docs,
12
+ // ~150k tokens as the first act of a session. Truncating descriptions would
13
+ // not have helped — they are uniformly long (p50 616 chars, p95 1,677, only
14
+ // 11 over 5k) — so selection is the only remedy.
15
+ //
16
+ // Append-only has no retraction, so an edge absent from a later chain node is
17
+ // not a retraction — every authored edge stays live. Dedup is by
18
+ // (source head, target head, type); a pair connected by two types keeps both.
19
+ // Entities are reached FROM the domain rather than filtered by a copy of its
20
+ // name — see _resolve.md. A target's domain is walked the same way.
21
+ MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)-[:DISCOVERED]->(e:Knowledge)
22
+ WHERE NOT (e)<-[:EVOLVED_FROM]-()
23
+ AND ($types IS NULL OR e.ont_type IN $types OR any(l IN labels(e) WHERE l IN $types))
24
+ WITH DISTINCT e
25
+ ORDER BY e.t_created DESC
26
+ __limit_clause__
27
+ OPTIONAL MATCH (e)-[:EVOLVED_FROM*0..]->(anc)-[r]->(o:Knowledge)
28
+ WHERE NOT type(r) IN $excluded
29
+ OPTIONAL MATCH (target:Knowledge)-[:EVOLVED_FROM*0..]->(o)
30
+ WHERE NOT (target)<-[:EVOLVED_FROM]-()
31
+ RETURN e.name AS name, e.ont_type AS type, labels(e) AS labels,
32
+ __description_expr__ AS description, e.t_created AS t_created,
33
+ collect(DISTINCT {
34
+ target: target.name,
35
+ target_domain: [(td:Domain)-[:HAS_SESSION]->(:Session)
36
+ -[:DISCOVERED]->(target) | td.name][0],
37
+ type: type(r)}) AS connections
38
+ ORDER BY e.t_created DESC
@@ -0,0 +1,3 @@
1
+ CREATE FULLTEXT INDEX knowledge_index IF NOT EXISTS
2
+ FOR (n:Actor|Structure|Artifact|Place|Event|Insight|Pattern|Challenge|Solution|Lesson|Rationale)
3
+ ON EACH [n.name, n.description]
@@ -0,0 +1,14 @@
1
+ // What already occupies a (name, domain) that create_knowledge just refused.
2
+ //
3
+ // Runs only on the collision path. The refusal names three options — evolve,
4
+ // confirm, rename — but choosing between them requires knowing what is there,
5
+ // so before this the agent had to search, read, decide, then act: four calls
6
+ // to make one decision. 89% of the production graph has never been evolved,
7
+ // which is what a four-call decision produces.
8
+ MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)
9
+ -[:DISCOVERED]->(existing:Knowledge {name: $name})
10
+ WHERE NOT (existing)<-[:EVOLVED_FROM]-()
11
+ RETURN DISTINCT existing.description AS description,
12
+ existing.ont_type AS ont_type,
13
+ labels(existing) AS labels,
14
+ toString(existing.t_created) AS t_created
@@ -0,0 +1,9 @@
1
+ MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)
2
+ -[:DISCOVERED]->(e:Knowledge {name: $name})
3
+ WHERE NOT (e)<-[:EVOLVED_FROM]-()
4
+ WITH DISTINCT e
5
+ MATCH (s:Session {session_id: $session_id})
6
+ MERGE (s)-[c:CONFIRMED]->(e)
7
+ ON CREATE SET c.t_created = datetime()
8
+ RETURN e.name AS name, e.ont_type AS type, labels(e) AS labels,
9
+ $domain AS domain, e.description AS description
@@ -0,0 +1,18 @@
1
+ // See _resolve.md — domain is a query parameter, resolved by walk.
2
+ OPTIONAL MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)
3
+ -[:DISCOVERED]->(existing:Knowledge {name: $name})
4
+ WHERE NOT (existing)<-[:EVOLVED_FROM]-()
5
+ WITH existing
6
+ WHERE existing IS NULL
7
+ MATCH (s:Session {session_id: $session_id})
8
+ CREATE (e:__labels__ {
9
+ name: $name,
10
+ description: $description,
11
+ ont_type: $ont_type,
12
+ t_created: datetime()
13
+ })
14
+ __sources_clause__
15
+ __t_valid_clause__
16
+ CREATE (s)-[d:DISCOVERED]->(e)
17
+ __basis_clause__
18
+ RETURN e.name AS name, e.ont_type AS type, labels(e) AS labels
@@ -0,0 +1,20 @@
1
+ // The successor is discovered by the evolving session, which is what places it
2
+ // in a domain. It inherits no domain property because there is none to inherit.
3
+ MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(:Session)
4
+ -[:DISCOVERED]->(old:Knowledge:`__ont_label__` {name: $name})
5
+ WHERE NOT (old)<-[:EVOLVED_FROM]-()
6
+ WITH DISTINCT old
7
+ MATCH (s:Session {session_id: $session_id})
8
+ CREATE (new:__new_labels__ {
9
+ name: old.name,
10
+ description: $new_description,
11
+ ont_type: $ont_type,
12
+ t_created: datetime()
13
+ })
14
+ __sources_clause__
15
+ __t_valid_clause__
16
+ CREATE (new)-[:EVOLVED_FROM]->(old)
17
+ CREATE (s)-[d:DISCOVERED]->(new)
18
+ __basis_clause__
19
+ RETURN new.name AS name, new.ont_type AS type, labels(new) AS labels,
20
+ new.description AS description, $domain AS domain
@@ -0,0 +1,24 @@
1
+ // A co-reference CLAIM between two knowledge-layer referents.
2
+ //
3
+ // Both endpoints are resolved by walking from their Domain — see _resolve.md —
4
+ // so a name living in eleven domains cannot produce a cross product, and a
5
+ // process node is unreachable by construction: a referent has no frame from
6
+ // which to perform an act of reference.
7
+ //
8
+ // `session_id` rides on the edge because a claim with no author is precisely
9
+ // what this edge exists to prevent — it is the vector that made the claim, not
10
+ // derivable from the endpoints. The claim's TIME is not stored: it is the
11
+ // session's timestamp, one hop away.
12
+ MATCH (:Domain {name: $source_domain})-[:HAS_SESSION]->(:Session)
13
+ -[:DISCOVERED]->(source:Knowledge {name: $source})
14
+ WHERE NOT (source)<-[:EVOLVED_FROM]-()
15
+ MATCH (:Domain {name: $target_domain})-[:HAS_SESSION]->(:Session)
16
+ -[:DISCOVERED]->(target:Knowledge {name: $target})
17
+ WHERE NOT (target)<-[:EVOLVED_FROM]-()
18
+ WITH DISTINCT source, target
19
+ MATCH (s:Session {session_id: $session_id})
20
+ MERGE (source)-[c:REFERENT_CLAIM]->(target)
21
+ SET c.resolution = $resolution, c.session_id = $session_id
22
+ RETURN source.name AS source, $source_domain AS source_domain,
23
+ target.name AS target, $target_domain AS target_domain,
24
+ c.resolution AS resolution, c.session_id AS session_id
@@ -0,0 +1,32 @@
1
+ // Connections between the entities a search actually returned.
2
+ //
3
+ // Matching is by NODE IDENTITY, not by name, and edges resolve THROUGH the
4
+ // chain. The prior version matched `source.name IN $names AND target.name IN
5
+ // $names` against head nodes only, which held three defects, all measured live:
6
+ // · no head-of-chain filter — a head shares its name with its own
7
+ // predecessors, so a chain's EVOLVED_FROM edges surfaced as self-loops
8
+ // (8 spurious rows on one `Dana Whitfield` search)
9
+ // · no domain scoping — an edge authored between two referents in one domain
10
+ // was reported between their same-named counterparts in another (9 live
11
+ // pairs, both directions)
12
+ // · no chain resolution — an edge authored before either endpoint evolved
13
+ // was unreachable (737 graph-wide)
14
+ //
15
+ // Cross-domain edges are NOT excluded: 138 are deliberately authored. The fix
16
+ // is identity, not same-domain confinement.
17
+ UNWIND $pairs AS pair
18
+ MATCH (:Domain {name: pair.domain})-[:HAS_SESSION]->(:Session)
19
+ -[:DISCOVERED]->(n:Knowledge {name: pair.name})
20
+ WHERE NOT (n)<-[:EVOLVED_FROM]-()
21
+ WITH collect(DISTINCT n) AS nodes
22
+ UNWIND nodes AS source
23
+ MATCH (source)-[:EVOLVED_FROM*0..]->(anc)-[r]->(o)
24
+ WHERE NOT type(r) IN $excluded
25
+ MATCH (target)-[:EVOLVED_FROM*0..]->(o)
26
+ WHERE NOT (target)<-[:EVOLVED_FROM]-()
27
+ AND target IN nodes
28
+ RETURN DISTINCT source.name AS source,
29
+ [(sd:Domain)-[:HAS_SESSION]->(:Session)-[:DISCOVERED]->(source) | sd.name][0] AS source_domain,
30
+ target.name AS target,
31
+ [(td:Domain)-[:HAS_SESSION]->(:Session)-[:DISCOVERED]->(target) | td.name][0] AS target_domain,
32
+ type(r) AS connectionType
@@ -0,0 +1,11 @@
1
+ CALL db.index.fulltext.queryNodes('knowledge_index', $query) YIELD node, score
2
+ WHERE NOT (node)<-[:EVOLVED_FROM]-()
3
+ WITH node, score,
4
+ [(d:Domain)-[:HAS_SESSION]->(:Session)-[:DISCOVERED]->(node) | d.name][0] AS domain
5
+ WHERE ($domain IS NULL OR domain = $domain)
6
+ WITH node, score, domain
7
+ ORDER BY score DESC
8
+ LIMIT $limit
9
+ RETURN node.name AS name, node.ont_type AS type, labels(node) AS labels,
10
+ domain, node.description AS description,
11
+ score, node.t_created AS t_created
@@ -0,0 +1,21 @@
1
+ MERGE (p:Person {source_key: $director_key})
2
+ ON CREATE SET p.name = $director
3
+ WITH p
4
+ CREATE (s:Session {
5
+ session_id: $session_id,
6
+ timestamp: datetime(), purpose: $purpose,
7
+ summary: '', status: $status
8
+ })
9
+ CREATE (p)-[:DIRECTED {method: 'platform-stated'}]->(s)
10
+ WITH s, p
11
+ MATCH (d:Domain {name: $domain})
12
+ CREATE (d)-[:HAS_SESSION]->(s)
13
+ WITH s, p
14
+ OPTIONAL MATCH (:Domain {name: $domain})-[:HAS_SESSION]->(prev:Session)
15
+ WHERE prev.session_id <> $session_id
16
+ WITH s, p, prev ORDER BY prev.timestamp DESC LIMIT 1
17
+ FOREACH (_ IN CASE WHEN prev IS NOT NULL THEN [1] ELSE [] END |
18
+ CREATE (prev)-[:NEXT_SESSION]->(s)
19
+ )
20
+ RETURN s.session_id AS session_id, s.timestamp AS timestamp,
21
+ p.name AS director, p.source_key AS director_key
@@ -0,0 +1,15 @@
1
+ MATCH (d:Domain {name: $domain})-[:HAS_SESSION]->(s:Session)
2
+ WITH s ORDER BY s.timestamp ASC
3
+ LIMIT $limit
4
+ OPTIONAL MATCH (dir:Person)-[:DIRECTED]->(s)
5
+ WITH s, dir
6
+ OPTIONAL MATCH (s)-[:DISCOVERED]->(e)
7
+ WITH s, dir, count(e) AS entities_discovered
8
+ OPTIONAL MATCH (s)-[:CONFIRMED]->(c)
9
+ WITH s, dir, entities_discovered, count(c) AS entities_confirmed
10
+ RETURN s.session_id AS session_id,
11
+ dir.name AS director, dir.source_key AS director_key,
12
+ s.timestamp AS timestamp, s.purpose AS purpose,
13
+ s.summary AS summary, s.status AS status,
14
+ entities_discovered, entities_confirmed
15
+ ORDER BY s.timestamp ASC
@@ -0,0 +1 @@
1
+ """Migration scripts for upgrading temporal knowledge graphs across versions."""
@@ -0,0 +1,259 @@
1
+ """Migration v0.6.x → v0.7.0.
2
+
3
+ Run with:
4
+ python -m kenning_continuity._migration.knowledge_focus \\
5
+ --db-url bolt://localhost:7687 \\
6
+ --username neo4j --password ... \\
7
+ --database <database-name> \\
8
+ [--dry-run]
9
+
10
+ `--database` is REQUIRED. Neo4j Desktop 2 hosts multiple named databases on
11
+ a single instance; specifying the wrong one is destructive.
12
+
13
+ Phases:
14
+ 1. Backup advisory (out-of-band — script does NOT take a backup; you must)
15
+ 2. Process layer rename (Run → Session, HAS_RUN → HAS_SESSION, etc.)
16
+ 3. Knowledge layer label renames (Gotcha → Lesson, Change → Event)
17
+ 4. Knowledge layer edge rename (RELATES_TO → ASSOCIATED_WITH)
18
+ 5. Add ontological labels (Actor on Person/Organization; Structure on
19
+ System/Process/Configuration)
20
+ 6. Add ont_type property to every knowledge node
21
+ 7. Dependency reclassification (operator-assisted — prints helper query)
22
+ 8. Fulltext index rebuild
23
+ 9. Verification queries
24
+
25
+ Each phase is independently reversible if the migration is wrong. Idempotent:
26
+ re-running the script after a partial failure picks up where it left off
27
+ (except Phase 1, which is advisory only).
28
+ """
29
+ import argparse
30
+ import asyncio
31
+ import logging
32
+ import sys
33
+ from typing import Any
34
+
35
+ from neo4j import AsyncGraphDatabase, RoutingControl
36
+
37
+
38
+ logger = logging.getLogger("kenning_continuity._migration.knowledge_focus")
39
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
40
+
41
+
42
+ # Phase queries -------------------------------------------------------------
43
+
44
+ PHASE_2_PROCESS_RENAMES = [
45
+ ("Session label", "MATCH (n:Run) REMOVE n:Run SET n:Session RETURN count(n) AS n"),
46
+ ("session_id property", "MATCH (n:Session) WHERE n.run_id IS NOT NULL "
47
+ "SET n.session_id = n.run_id REMOVE n.run_id RETURN count(n) AS n"),
48
+ ("HAS_RUN → HAS_SESSION", "MATCH (a)-[r:HAS_RUN]->(b) "
49
+ "MERGE (a)-[r2:HAS_SESSION]->(b) "
50
+ "ON CREATE SET r2 = properties(r) "
51
+ "DELETE r RETURN count(r2) AS n"),
52
+ ("NEXT_RUN → NEXT_SESSION", "MATCH (a)-[r:NEXT_RUN]->(b) "
53
+ "MERGE (a)-[r2:NEXT_SESSION]->(b) "
54
+ "ON CREATE SET r2 = properties(r) "
55
+ "DELETE r RETURN count(r2) AS n"),
56
+ ]
57
+
58
+ PHASE_3_KNOWLEDGE_LABEL_RENAMES = [
59
+ ("Gotcha → Lesson", "MATCH (n:Gotcha) REMOVE n:Gotcha SET n:Lesson RETURN count(n) AS n"),
60
+ ("Change → Event", "MATCH (n:Change) REMOVE n:Change SET n:Event RETURN count(n) AS n"),
61
+ ]
62
+
63
+ PHASE_4_EDGE_RENAMES = [
64
+ ("RELATES_TO → ASSOCIATED_WITH", "MATCH (a)-[r:RELATES_TO]->(b) "
65
+ "MERGE (a)-[r2:ASSOCIATED_WITH]->(b) "
66
+ "ON CREATE SET r2 = properties(r) "
67
+ "DELETE r RETURN count(r2) AS n"),
68
+ ]
69
+
70
+ PHASE_5_ONTOLOGICAL_LABELS = [
71
+ ("Actor (from Person/Organization)",
72
+ "MATCH (n) WHERE (n:Person OR n:Organization) AND NOT n:Actor "
73
+ "SET n:Actor RETURN count(n) AS n"),
74
+ ("Structure (from System/Process/Configuration)",
75
+ "MATCH (n) WHERE (n:System OR n:Process OR n:Configuration) AND NOT n:Structure "
76
+ "SET n:Structure RETURN count(n) AS n"),
77
+ ]
78
+
79
+ PHASE_6_ONT_TYPE_PROPERTY = [
80
+ ("Actor", "MATCH (n:Actor) WHERE n.ont_type IS NULL SET n.ont_type = 'Actor' RETURN count(n) AS n"),
81
+ ("Structure", "MATCH (n:Structure) WHERE n.ont_type IS NULL SET n.ont_type = 'Structure' RETURN count(n) AS n"),
82
+ ("Artifact", "MATCH (n:Artifact) WHERE n.ont_type IS NULL SET n.ont_type = 'Artifact' RETURN count(n) AS n"),
83
+ ("Event", "MATCH (n:Event) WHERE n.ont_type IS NULL SET n.ont_type = 'Event' RETURN count(n) AS n"),
84
+ ("Insight", "MATCH (n:Insight) WHERE n.ont_type IS NULL SET n.ont_type = 'Insight' RETURN count(n) AS n"),
85
+ ("Pattern", "MATCH (n:Pattern) WHERE n.ont_type IS NULL SET n.ont_type = 'Pattern' RETURN count(n) AS n"),
86
+ ("Challenge", "MATCH (n:Challenge) WHERE n.ont_type IS NULL SET n.ont_type = 'Challenge' RETURN count(n) AS n"),
87
+ ("Solution", "MATCH (n:Solution) WHERE n.ont_type IS NULL SET n.ont_type = 'Solution' RETURN count(n) AS n"),
88
+ ("Lesson", "MATCH (n:Lesson) WHERE n.ont_type IS NULL SET n.ont_type = 'Lesson' RETURN count(n) AS n"),
89
+ ("Rationale", "MATCH (n:Rationale) WHERE n.ont_type IS NULL SET n.ont_type = 'Rationale' RETURN count(n) AS n"),
90
+ ]
91
+
92
+ PHASE_7_DEPENDENCY_HELPER = (
93
+ "MATCH (d:Dependency) "
94
+ "OPTIONAL MATCH (d)-[r_out]->(out) "
95
+ "WHERE NOT type(r_out) IN ['DISCOVERED', 'CONFIRMED'] "
96
+ "OPTIONAL MATCH (in_node)-[r_in]->(d) "
97
+ "WHERE NOT type(r_in) IN ['DISCOVERED', 'CONFIRMED', 'HAS_SESSION', 'NEXT_SESSION'] "
98
+ "RETURN d.name AS name, d.domain AS domain, d.description AS description, "
99
+ " collect(DISTINCT {dir: 'out', target: out.name, type: type(r_out)}) AS outgoing, "
100
+ " collect(DISTINCT {dir: 'in', source: in_node.name, type: type(r_in)}) AS incoming"
101
+ )
102
+
103
+ PHASE_8_INDEX_REBUILD = [
104
+ ("Drop old fulltext index", "DROP INDEX knowledge_index IF EXISTS"),
105
+ ("Create new fulltext index",
106
+ "CREATE FULLTEXT INDEX knowledge_index IF NOT EXISTS "
107
+ "FOR (n:Actor|Structure|Artifact|Event|Insight|Pattern|Challenge|Solution|Lesson|Rationale) "
108
+ "ON EACH [n.name, n.description]"),
109
+ ]
110
+
111
+ PHASE_9_VERIFICATION = [
112
+ ("Every knowledge node has exactly one ontological label",
113
+ "MATCH (n) WHERE n.ont_type IS NOT NULL "
114
+ "WITH n, [l IN labels(n) WHERE l IN ['Actor','Structure','Artifact','Event',"
115
+ "'Insight','Pattern','Challenge','Solution','Lesson','Rationale']] AS ont_labels "
116
+ "WHERE size(ont_labels) <> 1 "
117
+ "RETURN n.name AS name, n.domain AS domain, labels(n) AS labels, ont_labels"),
118
+ ("Every knowledge node's ont_type matches its ontological label",
119
+ "MATCH (n) WHERE n.ont_type IS NOT NULL "
120
+ "WITH n, [l IN labels(n) WHERE l = n.ont_type][0] AS match "
121
+ "WHERE match IS NULL "
122
+ "RETURN n.name AS name, n.domain AS domain, n.ont_type AS ont_type, labels(n) AS labels"),
123
+ ("No legacy labels remain (Run/Gotcha/Change)",
124
+ "MATCH (n) WHERE n:Run OR n:Gotcha OR n:Change "
125
+ "RETURN labels(n) AS labels, count(n) AS n"),
126
+ ("No legacy edge types remain (HAS_RUN/NEXT_RUN/RELATES_TO)",
127
+ "MATCH ()-[r:HAS_RUN|NEXT_RUN|RELATES_TO]->() "
128
+ "RETURN type(r) AS type, count(r) AS n"),
129
+ ("Dependency nodes remaining (operator must reclassify per Phase 7)",
130
+ "MATCH (n:Dependency) RETURN count(n) AS n"),
131
+ ]
132
+
133
+
134
+ # Runner --------------------------------------------------------------------
135
+
136
+ async def _run_phase(driver, name: str, queries: list[tuple[str, str]], dry_run: bool) -> None:
137
+ logger.info(f"=== {name} ===")
138
+ for label, query in queries:
139
+ if dry_run:
140
+ logger.info(f" [DRY RUN] {label}: {query[:120]}{'…' if len(query) > 120 else ''}")
141
+ continue
142
+ try:
143
+ result = await driver.execute_query(query, routing_control=RoutingControl.WRITE)
144
+ if result.records:
145
+ count = result.records[0].get("n", "n/a")
146
+ logger.info(f" {label}: {count} affected")
147
+ else:
148
+ logger.info(f" {label}: no records returned")
149
+ except Exception as e:
150
+ logger.error(f" {label}: FAILED — {e}")
151
+ raise
152
+
153
+
154
+ async def _run_dependency_helper(driver, dry_run: bool) -> None:
155
+ logger.info("=== Phase 7: Dependency reclassification (operator-assisted) ===")
156
+ if dry_run:
157
+ logger.info(f" [DRY RUN] Would query: {PHASE_7_DEPENDENCY_HELPER[:120]}…")
158
+ return
159
+ result = await driver.execute_query(PHASE_7_DEPENDENCY_HELPER, routing_control=RoutingControl.READ)
160
+ if not result.records:
161
+ logger.info(" No Dependency nodes found. Phase 7 complete.")
162
+ return
163
+ logger.warning(f" Found {len(result.records)} Dependency nodes requiring operator review.")
164
+ logger.warning(" Reclassify each per the v0.7.0 plan:")
165
+ logger.warning(" REMOVE n:Dependency SET n:Structure:System SET n.ont_type = 'Structure' // common case")
166
+ logger.warning(" REMOVE n:Dependency SET n:Actor:Organization SET n.ont_type = 'Actor' // vendor case")
167
+ for record in result.records:
168
+ d: dict[str, Any] = dict(record)
169
+ logger.warning(f" - {d.get('domain')!r}/{d.get('name')!r}: {d.get('description', '')[:80]}")
170
+
171
+
172
+ async def _run_verification(driver) -> bool:
173
+ logger.info("=== Phase 9: Verification ===")
174
+ all_ok = True
175
+ for label, query in PHASE_9_VERIFICATION:
176
+ try:
177
+ result = await driver.execute_query(query, routing_control=RoutingControl.READ)
178
+ issues = result.records or []
179
+ if not issues:
180
+ logger.info(f" {label}: OK")
181
+ else:
182
+ # The "Dependency nodes remaining" check returns one row with count=0 in success case
183
+ if "Dependency nodes remaining" in label:
184
+ n = issues[0].get("n", 0) if issues else 0
185
+ if n == 0:
186
+ logger.info(f" {label}: OK (0 remaining)")
187
+ else:
188
+ logger.warning(f" {label}: {n} remain — Phase 7 incomplete")
189
+ else:
190
+ logger.error(f" {label}: {len(issues)} issue(s)")
191
+ for issue in issues[:5]:
192
+ logger.error(f" {dict(issue)}")
193
+ all_ok = False
194
+ except Exception as e:
195
+ logger.error(f" {label}: query FAILED — {e}")
196
+ all_ok = False
197
+ return all_ok
198
+
199
+
200
+ async def migrate(uri: str, user: str, password: str, database: str, dry_run: bool) -> int:
201
+ logger.warning("=" * 70)
202
+ logger.warning("Phase 1: BACKUP ADVISORY")
203
+ logger.warning("This script does NOT take a backup. Confirm you have one before continuing.")
204
+ logger.warning("Migration is destructive in spots. Press Ctrl+C now if unsure.")
205
+ logger.warning("=" * 70)
206
+
207
+ driver = AsyncGraphDatabase.driver(uri, auth=(user, password), database=database)
208
+ try:
209
+ if not dry_run:
210
+ await driver.verify_connectivity()
211
+ logger.info(f"Connected to Neo4j at {uri}")
212
+ else:
213
+ logger.info(f"[DRY RUN] Would connect to Neo4j at {uri}")
214
+
215
+ await _run_phase(driver, "Phase 2: Process layer rename", PHASE_2_PROCESS_RENAMES, dry_run)
216
+ await _run_phase(driver, "Phase 3: Knowledge label renames", PHASE_3_KNOWLEDGE_LABEL_RENAMES, dry_run)
217
+ await _run_phase(driver, "Phase 4: Edge rename (RELATES_TO → ASSOCIATED_WITH)", PHASE_4_EDGE_RENAMES, dry_run)
218
+ await _run_phase(driver, "Phase 5: Add ontological labels", PHASE_5_ONTOLOGICAL_LABELS, dry_run)
219
+ await _run_phase(driver, "Phase 6: Add ont_type property", PHASE_6_ONT_TYPE_PROPERTY, dry_run)
220
+ await _run_dependency_helper(driver, dry_run)
221
+ await _run_phase(driver, "Phase 8: Fulltext index rebuild", PHASE_8_INDEX_REBUILD, dry_run)
222
+
223
+ if dry_run:
224
+ logger.info("Dry run complete. No changes were made. Re-run without --dry-run to apply.")
225
+ return 0
226
+
227
+ ok = await _run_verification(driver)
228
+ if ok:
229
+ logger.info("Migration complete. v0.7.0 ready.")
230
+ return 0
231
+ else:
232
+ logger.error("Migration FAILED verification. Review issues above and address before using v0.7.0.")
233
+ return 2
234
+ finally:
235
+ await driver.close()
236
+
237
+
238
+ def main():
239
+ parser = argparse.ArgumentParser(
240
+ description="Migrate a temporal knowledge graph from v0.6.x to v0.7.0.",
241
+ formatter_class=argparse.RawDescriptionHelpFormatter,
242
+ epilog=__doc__,
243
+ )
244
+ parser.add_argument("--db-url", required=True, help="Neo4j bolt URL (e.g., bolt://localhost:7687)")
245
+ parser.add_argument("--username", default="neo4j", help="Neo4j username (default: neo4j)")
246
+ parser.add_argument("--password", required=True, help="Neo4j password")
247
+ parser.add_argument("--database", required=True, help="Neo4j database name (REQUIRED — Neo4j Desktop 2 hosts multiple named databases on one instance; running against the wrong one is destructive)")
248
+ parser.add_argument("--dry-run", action="store_true", help="Print phase queries without executing")
249
+ args = parser.parse_args()
250
+
251
+ rc = asyncio.run(migrate(
252
+ uri=args.db_url, user=args.username, password=args.password,
253
+ database=args.database, dry_run=args.dry_run,
254
+ ))
255
+ sys.exit(rc)
256
+
257
+
258
+ if __name__ == "__main__":
259
+ main()