Preserve the local graph-first investigation stack through macro V1. Include reviewed source, tests, contracts, documentation and synthetic assets. Retain the standalone financial V21 test foundation without runtime migration. Runtime remains V20; operational JobStore remains V3. Local experimental checkpoint, not a stable release.
231 lines
8.2 KiB
Python
231 lines
8.2 KiB
Python
"""Fixtures SPECIMEN déterministes, sans base d'enquête ni accès réseau."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import random
|
|
from copy import deepcopy
|
|
|
|
KINDS = (
|
|
"PERSON", "USERNAME", "EMAIL", "DOMAIN", "IP", "EVIDENCE",
|
|
"SOURCE", "OBSERVATION", "HYPOTHESIS", "TRANSACTION",
|
|
)
|
|
|
|
CAPABILITY_CATALOG = [
|
|
{"id": "focus-neighborhood", "intent": "Explorer le voisinage", "network_contact": "NONE", "experimental": True},
|
|
{"id": "show-provenance", "intent": "Remonter aux sources", "network_contact": "NONE", "experimental": True},
|
|
{"id": "rdap", "intent": "Interroger RDAP", "network_contact": "THIRD_PARTY", "experimental": True},
|
|
]
|
|
|
|
|
|
def _node(identifier, kind, label, index, total, *, state="observed", group=None, raw=None):
|
|
angle = (index / max(total, 1)) * math.tau
|
|
radius = 180 + (index % 7) * 24
|
|
return {
|
|
"id": identifier,
|
|
"object_kind": kind.lower(),
|
|
"object_id": f"SPECIMEN-{identifier}",
|
|
"type": kind,
|
|
"label": label,
|
|
"state": state,
|
|
"x": round(math.cos(angle) * radius, 3),
|
|
"y": round(math.sin(angle) * radius, 3),
|
|
"group": group,
|
|
"raw": raw,
|
|
}
|
|
|
|
|
|
def _capability(node, capability_id, available, reason):
|
|
# CONTRACT: l'applicabilité vient du serveur et porte l'identité métier ;
|
|
# l'interface n'infère aucune action depuis le seul type du nœud.
|
|
return {
|
|
"node_id": node["id"],
|
|
"object_ref": {"object_kind": node["object_kind"], "object_id": node["object_id"]},
|
|
"capability_id": capability_id,
|
|
"available": available,
|
|
"reason": reason,
|
|
}
|
|
|
|
|
|
def _demo_capabilities(nodes):
|
|
by_id = {node["id"]: node for node in nodes}
|
|
applications = [
|
|
_capability(node, "focus-neighborhood", True, "Navigation locale sur la fixture")
|
|
for node in nodes
|
|
]
|
|
for node_id in ("artifact", "transform", "observation"):
|
|
applications.append(
|
|
_capability(by_id[node_id], "show-provenance", True, "Origine synthétique disponible")
|
|
)
|
|
applications.append(
|
|
_capability(
|
|
by_id["domain"],
|
|
"rdap",
|
|
False,
|
|
"Simulation — aucun outil ni contact réseau exécuté",
|
|
)
|
|
)
|
|
return applications
|
|
|
|
|
|
def demo_snapshot():
|
|
specs = [
|
|
("person-a", "PERSON", "Camille Exemple", "identity", None),
|
|
("person-b", "PERSON", "Morgan Exemple", "identity", None),
|
|
("username", "USERNAME", "atlas_specimen", "identity", None),
|
|
("email", "EMAIL", "atlas@demo.test", "identity", None),
|
|
("domain", "DOMAIN", "demo.test", "infrastructure", None),
|
|
("ip", "IP", "192.0.2.42", "infrastructure", None),
|
|
("source", "SOURCE", "Source SPECIMEN A", "evidence", "Source locale synthétique A"),
|
|
("source-b", "SOURCE", "Source SPECIMEN B", "evidence", "Source locale synthétique B"),
|
|
("artifact", "EVIDENCE", "Artefact SPECIMEN", "evidence", "Brut synthétique : atlas@demo.test"),
|
|
("transform", "OBSERVATION", "Extraction déterministe", "evidence", "Transformation v1"),
|
|
("observation", "OBSERVATION", "Pseudo observé", "evidence", "atlas_specimen"),
|
|
("hypothesis-a", "HYPOTHESIS", "Identité candidate", "analysis", None),
|
|
("hypothesis-b", "HYPOTHESIS", "Hypothèse contradictoire", "analysis", None),
|
|
("transaction", "TRANSACTION", "42,00 EUR — fictif", "finance", "Montant fictif non vérifié"),
|
|
]
|
|
nodes = [
|
|
_node(
|
|
identifier,
|
|
kind,
|
|
label,
|
|
index,
|
|
len(specs),
|
|
state="under_review" if kind == "HYPOTHESIS" else "observed",
|
|
group=group,
|
|
raw=raw,
|
|
)
|
|
for index, (identifier, kind, label, group, raw) in enumerate(specs)
|
|
]
|
|
links = [
|
|
("e1", "person-a", "username", "business"),
|
|
("e2", "person-b", "username", "support"),
|
|
("e3", "email", "domain", "business"),
|
|
("e4", "domain", "ip", "business"),
|
|
("e5", "source", "artifact", "provenance"),
|
|
("e5b", "source-b", "artifact", "provenance"),
|
|
("e6", "artifact", "transform", "provenance"),
|
|
("e7", "transform", "observation", "provenance"),
|
|
("e8", "observation", "hypothesis-a", "support"),
|
|
("e9", "observation", "hypothesis-b", "contradiction"),
|
|
("e10", "person-a", "transaction", "business"),
|
|
("e11", "person-a", "username", "support"),
|
|
]
|
|
edges = [
|
|
{
|
|
"id": identifier,
|
|
"source": source,
|
|
"target": target,
|
|
"kind": kind,
|
|
"directed": True,
|
|
"review_state": "unreviewed",
|
|
"time_precision": None,
|
|
}
|
|
for identifier, source, target, kind in links
|
|
]
|
|
return {
|
|
"contract": "labfy.web_graph.snapshot.v1",
|
|
"investigation": {"id": "SPECIMEN-WEB-GRAPH", "label": "Enquête synthétique J2", "synthetic": True},
|
|
"revision": 1,
|
|
"nodes": nodes,
|
|
"edges": edges,
|
|
"capability_catalog": deepcopy(CAPABILITY_CATALOG),
|
|
"capabilities": _demo_capabilities(nodes),
|
|
}
|
|
|
|
|
|
def scenario_events():
|
|
# INVARIANT: les identifiants/révisions appartiennent au scénario serveur ;
|
|
# ils ne sont jamais calculés depuis un curseur fourni par le client.
|
|
return [
|
|
{
|
|
"contract": "labfy.web_graph.event.v1",
|
|
"id": "1",
|
|
"base_revision": 1,
|
|
"revision": 2,
|
|
"kind": "node_patch",
|
|
"node_id": "person-a",
|
|
"changes": {"label": "Camille Exemple · revue", "state": "under_review"},
|
|
"message": "Libellé et état synthétiques actualisés",
|
|
},
|
|
{
|
|
"contract": "labfy.web_graph.event.v1",
|
|
"id": "2",
|
|
"base_revision": 2,
|
|
"revision": 3,
|
|
"kind": "node_patch",
|
|
"node_id": "observation",
|
|
"changes": {"label": "Pseudo observé · confirmé par fixture"},
|
|
"message": "Deuxième mise à jour synthétique appliquée",
|
|
},
|
|
{
|
|
"contract": "labfy.web_graph.event.v1",
|
|
"id": "3",
|
|
"base_revision": 3,
|
|
"revision": 3,
|
|
"kind": "scenario_end",
|
|
"node_id": None,
|
|
"changes": {},
|
|
"message": "Fin normale du scénario synthétique",
|
|
},
|
|
]
|
|
|
|
|
|
def scenario_snapshot():
|
|
snapshot = demo_snapshot()
|
|
for event in scenario_events():
|
|
if event["kind"] != "node_patch":
|
|
continue
|
|
node = next(node for node in snapshot["nodes"] if node["id"] == event["node_id"])
|
|
node.update(event["changes"])
|
|
snapshot["revision"] = event["revision"]
|
|
return snapshot
|
|
|
|
|
|
def generated_snapshot(size, *, latest=False):
|
|
if size == "demo":
|
|
return scenario_snapshot() if latest else demo_snapshot()
|
|
count = int(size)
|
|
if count not in (100, 1000, 5000):
|
|
raise ValueError("taille non autorisée")
|
|
rng = random.Random(20260925 + count)
|
|
nodes = [
|
|
_node(
|
|
f"node-{index}",
|
|
KINDS[index % len(KINDS)],
|
|
f"SPECIMEN {KINDS[index % len(KINDS)]} {index}",
|
|
index,
|
|
count,
|
|
group=f"group-{index % 12}",
|
|
)
|
|
for index in range(count)
|
|
]
|
|
edges = []
|
|
for index in range(count * 2):
|
|
source = index % count
|
|
target = (source + 1 + rng.randrange(max(1, min(count - 1, 31)))) % count
|
|
edges.append(
|
|
{
|
|
"id": f"edge-{index}",
|
|
"source": f"node-{source}",
|
|
"target": f"node-{target}",
|
|
"kind": ("business", "support", "provenance", "contradiction")[index % 4],
|
|
"directed": True,
|
|
"review_state": "unreviewed",
|
|
"time_precision": None,
|
|
}
|
|
)
|
|
base = demo_snapshot()
|
|
base["investigation"] = {
|
|
"id": f"SPECIMEN-GENERATED-{count}",
|
|
"label": f"Jeu synthétique {count}",
|
|
"synthetic": True,
|
|
}
|
|
base["nodes"] = nodes
|
|
base["edges"] = edges
|
|
base["capabilities"] = [
|
|
_capability(node, "focus-neighborhood", True, "Navigation locale sur la fixture générée")
|
|
for node in nodes
|
|
]
|
|
return base
|