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.
- kenning_continuity/__init__.py +34 -0
- kenning_continuity/_cypher/_resolve.md +16 -0
- kenning_continuity/_cypher/chain_history.cypher +14 -0
- kenning_continuity/_cypher/domain_merge.cypher +5 -0
- kenning_continuity/_cypher/domain_state.cypher +38 -0
- kenning_continuity/_cypher/index_create.cypher +3 -0
- kenning_continuity/_cypher/knowledge_collision.cypher +14 -0
- kenning_continuity/_cypher/knowledge_confirm.cypher +9 -0
- kenning_continuity/_cypher/knowledge_create.cypher +18 -0
- kenning_continuity/_cypher/knowledge_evolve.cypher +20 -0
- kenning_continuity/_cypher/referent_claim.cypher +24 -0
- kenning_continuity/_cypher/search_connections.cypher +32 -0
- kenning_continuity/_cypher/search_knowledge.cypher +11 -0
- kenning_continuity/_cypher/session_create.cypher +21 -0
- kenning_continuity/_cypher/session_history.cypher +15 -0
- kenning_continuity/_migration/__init__.py +1 -0
- kenning_continuity/_migration/knowledge_focus.py +259 -0
- kenning_continuity/_migration/person_layer.py +517 -0
- kenning_continuity/server.py +816 -0
- kenning_continuity/substrate.py +1580 -0
- kenning_continuity/utils.py +360 -0
- kenning_continuity-0.1.0.dist-info/METADATA +343 -0
- kenning_continuity-0.1.0.dist-info/RECORD +28 -0
- kenning_continuity-0.1.0.dist-info/WHEEL +4 -0
- kenning_continuity-0.1.0.dist-info/entry_points.txt +2 -0
- kenning_continuity-0.1.0.dist-info/licenses/LICENSE +202 -0
- kenning_continuity-0.1.0.dist-info/licenses/NOTICE +14 -0
- 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,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,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()
|