feat(concept_topology): port to FULL Rust — dichotomy wave

RUST verdict (dichotomy law, #2479 tranche, contract
BUILDER-2026-10-11T23:55:54Z-982aa3-factory-wave): concept_topology.py's
full learned-concept surface is ported to src/rust/concept_topology —
cofire (pair edges +1.0, sorted '|'-joined keys, first-sight topic
creation, seen counting), decay (weight *0.95 with the >= 0.5 keep
boundary), clusters (union-find connected components over edges >=
threshold, union by second-root repoint, biggest-component-first stable
order, sorted members), suggest (max-overlap cluster routing with
first-wins tie-break, new_topic fallback with the exact reason strings),
and new_topology (the empty seed).

Wiring: ct_loader.py ctypes loader mirroring broker_loader.py (doc-envelope
FFI, free via capability_scoring cs_free_string — no free symbol
re-export), facade delegation in concept_topology.py with RUNE_CT_CORE=python
fallback pin.

Truth: frozen_ct_truth.json — 36 cases from the live oracle (deep-copied
inputs, RUNE_CT_CORE=python pinned), replayed VERBATIM by the in-crate
branch battery. Battery caught one real port bug pre-landing (new cofire
edges got weight 0 instead of 1). Live parity: 386/386 checks (36 frozen +
613 fuzz ops + facade pin-vs-auto). Python tests green both modes; clippy 0.
This commit is contained in:
Nick committed 2026-10-12 02:36:18 +01:00
1 parent 0946141074
commit 5844426bdd
8 files changed
+1423

No files matched your search

+38
View File
@@ -9,6 +9,13 @@ it belongs to — or proposes a new topic when nothing fits.
No preset categories anywhere: the seed set is empty, and every
category in the topology was earned by co-firing.
Dichotomy wave #2479 tranche (contract
BUILDER-2026-10-11T23:55:54Z-982aa3-factory-wave): the deterministic
topology kernel is ported to FULL Rust (src/rust/concept_topology,
parity oracle frozen_ct_truth.json). This facade delegates to the Rust
core via rune/ct_loader when built; the Python implementation below is
the strangler fallback, pinned with RUNE_CT_CORE=python.
"""
from __future__ import annotations
@@ -17,9 +24,28 @@ DECAY_PER_TICK = 0.95
CLUSTER_THRESHOLD = 2.0
def _core():
"""The Rust concept_topology core when available (strangler: Python
fallback is load-bearing). RUNE_CT_CORE=python pins the fallback."""
import os
if os.environ.get("RUNE_CT_CORE", "auto") == "python":
return None
try:
from rune import ct_loader
except Exception: # noqa: BLE001 — any loader failure means fallback, never a broken call
return None
try:
return ct_loader if ct_loader.available() else None
except Exception: # noqa: BLE001 — availability probe must never crash the fallback
return None
def cofire(topology: dict, topics: list) -> dict:
"""Record one co-firing event: every pair in `topics` gains an edge
(weight +1). Topics are created on first sight (no preset list)."""
core = _core()
if core is not None:
return core.cofire(topology, topics)
topology = dict(topology)
edges = dict(topology.get("edges", {}))
seen = dict(topology.get("seen", {}))
@@ -36,6 +62,9 @@ def cofire(topology: dict, topics: list) -> dict:
def decay(topology: dict) -> dict:
"""Decay all edges one tick (stale associations weaken)."""
core = _core()
if core is not None:
return core.decay(topology)
topology = dict(topology)
edges = {k: v * DECAY_PER_TICK for k, v in topology.get("edges", {}).items()
if v * DECAY_PER_TICK >= 0.5}
@@ -45,6 +74,9 @@ def decay(topology: dict) -> dict:
def clusters(topology: dict, threshold: float = CLUSTER_THRESHOLD) -> list:
"""Emergent clusters: connected components over edges >= threshold."""
core = _core()
if core is not None:
return core.clusters(topology, threshold)
edges = {k: v for k, v in topology.get("edges", {}).items()
if v >= threshold}
parent: dict = {}
@@ -73,6 +105,9 @@ def clusters(topology: dict, threshold: float = CLUSTER_THRESHOLD) -> list:
def suggest(topology: dict, fact_topics: list) -> dict:
"""Suggest the cluster a new fact belongs to — or a new topic."""
core = _core()
if core is not None:
return core.suggest(topology, fact_topics)
cls = clusters(topology)
best, best_overlap = None, 0
for c in cls:
@@ -87,4 +122,7 @@ def suggest(topology: dict, fact_topics: list) -> dict:
def new_topology() -> dict:
"""The empty seed: no preset categories."""
core = _core()
if core is not None:
return core.new_topology()
return {"edges": {}, "seen": {}}
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""ct_loader.py — ctypes loader for the Rust concept_topology core
(dichotomy wave #2479 tranche, contract
BUILDER-2026-10-11T23:55:54Z-982aa3-factory-wave). Mirrors broker_loader.py:
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
* ct_cofire({"topology", "topics"}) -> topology dict
* ct_decay({"topology"}) -> topology dict
* ct_clusters({"topology", "threshold"?}) -> cluster list
* ct_suggest({"topology", "fact_topics"}) -> suggestion dict
* ct_new_topology({}) -> empty topology
Free: capability_scoring's cs_free_string (shared process). NEVER re-export
a free symbol here. Override the .so with CT_RUST_LIB (a FILE path).
"""
from __future__ import annotations
import ctypes
import json
import os
from pathlib import Path
_REPO = Path(__file__).resolve().parents[2]
_CRATE_DIR = _REPO / "src" / "rust" / "concept_topology"
_LIB_PATH = Path(
os.environ.get("CT_RUST_LIB", _CRATE_DIR / "target" / "release" / "libconcept_topology.so")
)
_lib = None
class ConceptTopologyUnavailable(FileNotFoundError):
"""The Rust topology core is not built. Callers fall back to Python
(strangler law; RUNE_CT_CORE=python pins the fallback)."""
def available() -> bool:
return _LIB_PATH.is_file()
def _load() -> ctypes.CDLL:
global _lib
if _lib is not None:
return _lib
if not _LIB_PATH.is_file():
raise ConceptTopologyUnavailable(str(_LIB_PATH))
lib = ctypes.CDLL(str(_LIB_PATH))
for sym in ("ct_cofire", "ct_decay", "ct_clusters", "ct_suggest", "ct_new_topology"):
fn = getattr(lib, sym)
fn.argtypes = [ctypes.c_void_p]
fn.restype = ctypes.c_void_p # char* — never default c_int
# free comes from capability_scoring (same process); load it if not yet resident
try:
lib.cs_free_string
except AttributeError:
scoring = ctypes.CDLL(str(
_REPO / "src" / "rust" / "capability_scoring"
/ "target" / "release" / "libcapability_scoring.so"
))
scoring.cs_free_string.argtypes = [ctypes.c_void_p]
lib._free = scoring.cs_free_string
_lib = lib
return lib
def _call(sym: str, payload: dict):
lib = _load()
data = json.dumps(payload).encode()
buf = ctypes.create_string_buffer(data)
ptr = getattr(lib, sym)(ctypes.cast(buf, ctypes.c_void_p))
if not ptr:
raise RuntimeError(f"{sym} returned null")
out = ctypes.string_at(ptr).decode()
free = getattr(lib, "_free", None)
if free is not None:
free(ptr)
doc = json.loads(out)
if not doc.get("ok"):
raise RuntimeError(doc.get("err", f"{sym} failed"))
return doc["value"]
def cofire(topology: dict, topics: list) -> dict:
return _call("ct_cofire", {"topology": topology, "topics": topics})
def decay(topology: dict) -> dict:
return _call("ct_decay", {"topology": topology})
def clusters(topology: dict, threshold: float | None = None) -> list:
payload = {"topology": topology}
if threshold is not None:
payload["threshold"] = threshold
return _call("ct_clusters", payload)
def suggest(topology: dict, fact_topics: list) -> dict:
return _call("ct_suggest", {"topology": topology, "fact_topics": fact_topics})
def new_topology() -> dict:
return _call("ct_new_topology", {})
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "capability_scoring"
version = "0.1.0"
[[package]]
name = "concept_topology"
version = "0.1.0"
dependencies = [
"capability_scoring",
]
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "concept_topology"
version = "0.1.0"
edition = "2021"
description = "#159 adaptive conceptual topology — dichotomy wave #2479 tranche; deterministic topology kernel in Rust per contract BUILDER-2026-10-11T23:55:54Z-982aa3-factory-wave; zero-dep (pyjson via capability_scoring)"
[lib]
name = "concept_topology"
crate-type = ["rlib", "cdylib", "staticlib"]
[dependencies]
capability_scoring = { path = "../capability_scoring" }
@@ -0,0 +1,795 @@
{
"module": "concept_topology",
"frozen_at": "2026-10-12T00:00:00Z",
"oracle": "src/rune/concept_topology.py",
"pin": "RUNE_CT_CORE=python",
"cases": [
{
"fn": "new_topology",
"args": {},
"expected": {
"edges": {},
"seen": {}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": []
},
"expected": {
"edges": {},
"seen": {}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": [
"a"
]
},
"expected": {
"edges": {},
"seen": {
"a": 1
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": [
"b",
"a"
]
},
"expected": {
"edges": {
"a|b": 1.0
},
"seen": {
"b": 1,
"a": 1
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": [
"a",
"b",
"c"
]
},
"expected": {
"edges": {
"a|b": 1.0,
"a|c": 1.0,
"b|c": 1.0
},
"seen": {
"a": 1,
"b": 1,
"c": 1
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": [
"x",
"x"
]
},
"expected": {
"edges": {
"x|x": 1.0
},
"seen": {
"x": 2
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": [
"caf\u00e9",
"na\u00efve"
]
},
"expected": {
"edges": {
"caf\u00e9|na\u00efve": 1.0
},
"seen": {
"caf\u00e9": 1,
"na\u00efve": 1
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {
"coffee|morning": 1.0
},
"seen": {
"coffee": 1,
"morning": 1
}
},
"topics": [
"coffee",
"morning"
]
},
"expected": {
"edges": {
"coffee|morning": 2.0
},
"seen": {
"coffee": 2,
"morning": 2
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {
"a|b": 0.4,
"b|c": 1.25
},
"seen": {
"a": 2,
"b": 1,
"c": 3,
"z": 7
}
},
"topics": [
"a",
"c"
]
},
"expected": {
"edges": {
"a|b": 0.4,
"b|c": 1.25,
"a|c": 1.0
},
"seen": {
"a": 3,
"b": 1,
"c": 4,
"z": 7
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"topics": [
"a|b",
"c"
]
},
"expected": {
"edges": {
"a|b|c": 1.0
},
"seen": {
"a|b": 1,
"c": 1
}
}
},
{
"fn": "cofire",
"args": {
"topology": {
"edges": {
"deploy|pipeline": 2.0
},
"seen": {
"deploy": 2,
"pipeline": 2
}
},
"topics": [
"deploy"
]
},
"expected": {
"edges": {
"deploy|pipeline": 2.0
},
"seen": {
"deploy": 3,
"pipeline": 2
}
}
},
{
"fn": "decay",
"args": {
"topology": {
"edges": {},
"seen": {}
}
},
"expected": {
"edges": {},
"seen": {}
}
},
{
"fn": "decay",
"args": {
"topology": {
"edges": {
"a|b": 1.0,
"b|c": 2.0
},
"seen": {
"a": 1
}
}
},
"expected": {
"edges": {
"a|b": 0.95,
"b|c": 1.9
},
"seen": {
"a": 1
}
}
},
{
"fn": "decay",
"args": {
"topology": {
"edges": {
"a|b": 0.5
},
"seen": {}
}
},
"expected": {
"edges": {},
"seen": {}
}
},
{
"fn": "decay",
"args": {
"topology": {
"edges": {
"a|b": 0.5263157894736842
},
"seen": {}
}
},
"expected": {
"edges": {},
"seen": {}
}
},
{
"fn": "decay",
"args": {
"topology": {
"edges": {
"a|b": 0.95
},
"seen": {
"x": 4
}
}
},
"expected": {
"edges": {
"a|b": 0.9025
},
"seen": {
"x": 4
}
}
},
{
"fn": "decay",
"args": {
"topology": {
"edges": {},
"seen": {
"k": 9
}
}
},
"expected": {
"edges": {},
"seen": {
"k": 9
}
}
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {},
"seen": {}
}
},
"expected": []
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"a|b": 1.5
},
"seen": {}
}
},
"expected": []
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"a|b": 2.0
},
"seen": {}
}
},
"expected": [
[
"a",
"b"
]
]
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"coffee|morning": 3.0,
"coffee|routine": 3.0,
"morning|routine": 3.0
},
"seen": {
"coffee": 3,
"morning": 3,
"routine": 3
}
}
},
"expected": [
[
"coffee",
"morning",
"routine"
]
]
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"deploy|pipeline": 3.0,
"coffee|morning": 2.0,
"coffee|routine": 2.0
},
"seen": {
"deploy": 3,
"pipeline": 3,
"coffee": 4,
"morning": 2,
"routine": 2
}
}
},
"expected": [
[
"coffee",
"morning",
"routine"
],
[
"deploy",
"pipeline"
]
]
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"deploy|pipeline": 3.0,
"coffee|morning": 2.0,
"coffee|routine": 2.0
},
"seen": {
"deploy": 3,
"pipeline": 3,
"coffee": 4,
"morning": 2,
"routine": 2
}
},
"threshold": 3.0
},
"expected": [
[
"deploy",
"pipeline"
]
]
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"deploy|pipeline": 3.0,
"coffee|morning": 2.0,
"coffee|routine": 2.0
},
"seen": {
"deploy": 3,
"pipeline": 3,
"coffee": 4,
"morning": 2,
"routine": 2
}
},
"threshold": 10.0
},
"expected": []
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"x|x": 2.0
},
"seen": {
"x": 2
}
}
},
"expected": [
[
"x"
]
]
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"a|b|c": 2.0
},
"seen": {
"a|b": 2,
"c": 2
}
}
},
"expected": [
[
"a",
"b|c"
]
]
},
{
"fn": "clusters",
"args": {
"topology": {
"edges": {
"p|q": 2.0,
"q|r": 2.0,
"p|r": 2.0,
"s|t": 3.0
},
"seen": {
"p": 4,
"q": 4,
"r": 4,
"s": 3,
"t": 3
}
}
},
"expected": [
[
"p",
"q",
"r"
],
[
"s",
"t"
]
]
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {},
"seen": {}
},
"fact_topics": [
"coffee"
]
},
"expected": {
"suggestion": "new_topic",
"reason": "no cluster shares a topic with this fact"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"coffee|morning": 3.0
},
"seen": {
"coffee": 3,
"morning": 3
}
},
"fact_topics": [
"coffee",
"evening"
]
},
"expected": {
"suggestion": [
"coffee",
"morning"
],
"reason": "shares 1 topic(s)"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"coffee|morning": 3.0
},
"seen": {
"coffee": 3,
"morning": 3
}
},
"fact_topics": [
"coffee",
"morning"
]
},
"expected": {
"suggestion": [
"coffee",
"morning"
],
"reason": "shares 2 topic(s)"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"coffee|morning": 3.0
},
"seen": {
"coffee": 3,
"morning": 3
}
},
"fact_topics": [
"quantum",
"physics"
]
},
"expected": {
"suggestion": "new_topic",
"reason": "no cluster shares a topic with this fact"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"coffee|morning": 3.0
},
"seen": {
"coffee": 3,
"morning": 3
}
},
"fact_topics": []
},
"expected": {
"suggestion": "new_topic",
"reason": "no cluster shares a topic with this fact"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"coffee|morning": 3.0
},
"seen": {
"coffee": 3,
"morning": 3
}
},
"fact_topics": [
"coffee",
"coffee"
]
},
"expected": {
"suggestion": [
"coffee",
"morning"
],
"reason": "shares 1 topic(s)"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"alpha|beta": 3.0,
"alpha|gamma": 3.0,
"beta|gamma": 3.0,
"alpha|delta": 1.0,
"s|t": 2.0
},
"seen": {
"alpha": 4,
"beta": 3,
"gamma": 3,
"delta": 1,
"s": 2,
"t": 2
}
},
"fact_topics": [
"delta",
"t"
]
},
"expected": {
"suggestion": [
"s",
"t"
],
"reason": "shares 1 topic(s)"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"alpha|beta": 3.0,
"alpha|gamma": 3.0,
"beta|gamma": 3.0,
"alpha|delta": 1.0,
"s|t": 2.0
},
"seen": {
"alpha": 4,
"beta": 3,
"gamma": 3,
"delta": 1,
"s": 2,
"t": 2
}
},
"fact_topics": [
"gamma",
"t"
]
},
"expected": {
"suggestion": [
"alpha",
"beta",
"gamma"
],
"reason": "shares 1 topic(s)"
}
},
{
"fn": "suggest",
"args": {
"topology": {
"edges": {
"alpha|beta": 3.0,
"alpha|gamma": 3.0,
"beta|gamma": 3.0,
"alpha|delta": 1.0,
"s|t": 2.0
},
"seen": {
"alpha": 4,
"beta": 3,
"gamma": 3,
"delta": 1,
"s": 2,
"t": 2
}
},
"fact_topics": [
"beta",
"beta",
"s"
]
},
"expected": {
"suggestion": [
"alpha",
"beta",
"gamma"
],
"reason": "shares 1 topic(s)"
}
}
]
}
@@ -0,0 +1,100 @@
//! branch_battery — replays frozen_ct_truth.json VERBATIM (36 cases
//! frozen from the live Python oracle 2026-10-12, RUNE_CT_CORE=python
//! pinned, inputs deep-copied before capture). Order-insensitive object
//! comparison; floats compared exactly (both sides IEEE-754 f64, and the
//! decay boundary case 10/19·0.95 is frozen as the exact dropped result).
use capability_scoring::pyjson::{parse, Json};
use crate::topology;
const TRUTH: &str = include_str!("../frozen_ct_truth.json");
fn jeq(a: &Json, b: &Json) -> bool {
match (a, b) {
(Json::Num(x), Json::Num(y)) => x == y,
(Json::Str(x), Json::Str(y)) => x == y,
(Json::Bool(x), Json::Bool(y)) => x == y,
(Json::Null, Json::Null) => true,
(Json::Arr(x), Json::Arr(y)) => {
x.len() == y.len() && x.iter().zip(y).all(|(i, j)| jeq(i, j))
}
(Json::Obj(x), Json::Obj(y)) => {
x.len() == y.len()
&& x.iter()
.all(|(k, v)| y.iter().any(|(k2, v2)| k == k2 && jeq(v, v2)))
}
_ => false,
}
}
fn as_obj(v: &Json) -> Json {
match v {
x @ Json::Obj(_) => x.clone(),
_ => Json::Obj(vec![]),
}
}
fn as_arr(v: &Json) -> Vec<Json> {
match v {
Json::Arr(a) => a.clone(),
_ => vec![],
}
}
fn run_case(fn_name: &str, args: &Json) -> Json {
match fn_name {
"new_topology" => topology::new_topology(),
"cofire" => topology::cofire(&as_obj(&args.get("topology").cloned().unwrap_or(Json::Null)),
&as_arr(&args.get("topics").cloned().unwrap_or(Json::Arr(vec![])))),
"decay" => topology::decay(&as_obj(&args.get("topology").cloned().unwrap_or(Json::Null))),
"clusters" => {
let t = as_obj(&args.get("topology").cloned().unwrap_or(Json::Null));
let threshold = match args.get("threshold") {
Some(Json::Num(n)) => *n,
_ => topology::CLUSTER_THRESHOLD,
};
Json::Arr(topology::clusters(&t, threshold))
}
"suggest" => topology::suggest(&as_obj(&args.get("topology").cloned().unwrap_or(Json::Null)),
&as_arr(&args.get("fact_topics").cloned().unwrap_or(Json::Arr(vec![])))),
other => panic!("unknown frozen fn {other}"),
}
}
#[test]
fn frozen_truth_replays_verbatim() {
let truth = parse(TRUTH).expect("frozen_ct_truth.json parses");
let cases = as_arr(truth.get("cases").expect("cases key"));
assert!(cases.len() >= 36, "expected the full frozen battery, got {}", cases.len());
let mut failures: Vec<String> = vec![];
for (i, case) in cases.iter().enumerate() {
let fn_name = match case.get("fn") {
Some(Json::Str(s)) => s.as_str(),
_ => panic!("case {i} missing fn"),
};
let args = as_obj(&case.get("args").cloned().unwrap_or(Json::Obj(vec![])));
let expected = case.get("expected").expect("expected key");
let actual = run_case(fn_name, &args);
if !jeq(expected, &actual) {
failures.push(format!(
"case {i} {fn_name}: args={} expected={} actual={}",
capability_scoring::pyjson::dumps(&args),
capability_scoring::pyjson::dumps(expected),
capability_scoring::pyjson::dumps(&actual),
));
}
}
assert!(
failures.is_empty(),
"frozen-truth divergences ({}):\n{}",
failures.len(),
failures.join("\n")
);
}
#[test]
fn battery_constants_match_oracle() {
assert_eq!(topology::DECAY_PER_TICK, 0.95);
assert_eq!(topology::CLUSTER_THRESHOLD, 2.0);
}
+124
View File
@@ -0,0 +1,124 @@
//! concept_topology — FFI surface for the topology kernel (dichotomy
//! wave #2479 tranche, contract BUILDER-2026-10-11T23:55:54Z-982aa3).
//! GO verdict: deterministic numeric core in Rust; Python facade keeps a
//! RUNE_CT_CORE=python pin.
//!
//! FFI contract (doc-envelope law — every call returns {"ok","value"|"err"}):
//! * ct_cofire({"topology": {...}, "topics": [...]}) -> topology dict
//! * ct_decay({"topology": {...}}) -> topology dict
//! * ct_clusters({"topology": {...}, "threshold": number?}) -> cluster list
//! * ct_suggest({"topology": {...}, "fact_topics": [...]}) -> suggestion dict
//! * ct_new_topology({}) -> empty topology
//!
//! Parity oracle: frozen_ct_truth.json (36 cases, frozen from the live
//! Python oracle 2026-10-12).
pub mod topology;
pub use capability_scoring::pyjson;
#[path = "branch_battery.rs"]
#[cfg(test)]
mod branch_battery;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use pyjson::Json;
fn read_arg(ptr: *const c_char) -> Result<Json, String> {
if ptr.is_null() {
return Err("null argument pointer".into());
}
let x = unsafe { CStr::from_ptr(ptr) }
.to_str()
.map_err(|e| e.to_string())?;
pyjson::parse(x)
}
fn envelope(r: Result<Json, String>) -> *mut c_char {
let v = match r {
Ok(x) => Json::Obj(vec![
("ok".into(), Json::Bool(true)),
("value".into(), x),
]),
Err(e) => Json::Obj(vec![
("ok".into(), Json::Bool(false)),
("err".into(), Json::Str(e)),
]),
};
CString::new(pyjson::dumps(&v))
.map(|c| c.into_raw())
.unwrap_or(std::ptr::null_mut())
}
fn obj_arg(v: &Json, key: &str) -> Json {
match v.get(key) {
Some(x @ Json::Obj(_)) => x.clone(),
_ => Json::Obj(vec![]),
}
}
fn arr_arg(v: &Json, key: &str) -> Vec<Json> {
match v.get(key) {
Some(Json::Arr(a)) => a.clone(),
_ => vec![],
}
}
/// ct_cofire({"topology": {...}, "topics": [...]})
#[no_mangle]
pub extern "C" fn ct_cofire(arg: *const c_char) -> *mut c_char {
let r = read_arg(arg).map(|a| {
let topology = obj_arg(&a, "topology");
let topics = arr_arg(&a, "topics");
topology::cofire(&topology, &topics)
});
envelope(r)
}
/// ct_decay({"topology": {...}})
#[no_mangle]
pub extern "C" fn ct_decay(arg: *const c_char) -> *mut c_char {
let r = read_arg(arg)
.map(|a| topology::decay(&obj_arg(&a, "topology")));
envelope(r)
}
/// ct_clusters({"topology": {...}, "threshold": number?}) — threshold
/// defaults to CLUSTER_THRESHOLD (2.0) when absent or null.
#[no_mangle]
pub extern "C" fn ct_clusters(arg: *const c_char) -> *mut c_char {
let r = read_arg(arg).map(|a| {
let topology = obj_arg(&a, "topology");
let threshold = match a.get("threshold") {
Some(Json::Num(n)) => *n,
_ => topology::CLUSTER_THRESHOLD,
};
Json::Arr(topology::clusters(&topology, threshold))
});
envelope(r)
}
/// ct_suggest({"topology": {...}, "fact_topics": [...]})
#[no_mangle]
pub extern "C" fn ct_suggest(arg: *const c_char) -> *mut c_char {
let r = read_arg(arg).map(|a| {
let topology = obj_arg(&a, "topology");
let fact_topics = arr_arg(&a, "fact_topics");
topology::suggest(&topology, &fact_topics)
});
envelope(r)
}
/// ct_new_topology({})
#[no_mangle]
pub extern "C" fn ct_new_topology(arg: *const c_char) -> *mut c_char {
let r = read_arg(arg).map(|_| topology::new_topology());
envelope(r)
}
// NOTE: ct_free_string is deliberately NOT exported — capability_scoring
// (linked into the same Python process via rune/cs_loader) already exports
// cs_free_string, and a duplicate free symbol breaks the cdylib link.
// Callers free every returned pointer through that path.
+237
View File
@@ -0,0 +1,237 @@
//! concept_topology core — port of src/rune/concept_topology.py (the
//! learned concept layer, #159): co-firing grows edges, decay weakens
//! stale associations, connected components above a weight threshold are
//! emergent clusters, suggest routes a fact to its cluster or proposes a
//! new topic. No preset categories anywhere.
//!
//! Parity oracle: frozen_ct_truth.json (36 cases frozen from the live
//! Python oracle 2026-10-12, RUNE_CT_CORE=python pinned).
use capability_scoring::pyjson::Json;
pub const DECAY_PER_TICK: f64 = 0.95;
pub const CLUSTER_THRESHOLD: f64 = 2.0;
/// Python dict-update semantics on an ordered pair vec: replace in place
/// when the key exists, append when new.
fn upsert(pairs: &mut Vec<(String, Json)>, key: &str, value: Json) {
if let Some(slot) = pairs.iter_mut().find(|(k, _)| k == key) {
slot.1 = value;
} else {
pairs.push((key.to_string(), value));
}
}
pub fn obj_get<'a>(v: &'a Json, key: &str) -> Option<&'a Json> {
v.get(key)
}
fn num_get(pairs: &[(String, Json)], key: &str) -> Option<f64> {
pairs
.iter()
.find(|(k, _)| k == key)
.and_then(|(_, v)| match v {
Json::Num(n) => Some(*n),
_ => None,
})
}
/// Port of new_topology(): the empty seed, no preset categories.
pub fn new_topology() -> Json {
Json::Obj(vec![
("edges".into(), Json::Obj(vec![])),
("seen".into(), Json::Obj(vec![])),
])
}
/// Port of cofire(): every pair in `topics` gains an edge (weight +1);
/// topics are created on first sight.
pub fn cofire(topology: &Json, topics: &[Json]) -> Json {
let mut pairs = match topology {
Json::Obj(p) => p.clone(),
_ => vec![],
};
let mut seen = match obj_get(topology, "seen") {
Some(Json::Obj(p)) => p.clone(),
_ => vec![],
};
for t in topics {
let key = match t {
Json::Str(s) => s.clone(),
other => capability_scoring::pyjson::dumps(other),
};
let next = num_get(&seen, &key).unwrap_or(0.0) + 1.0;
upsert(&mut seen, &key, Json::Num(next));
}
let mut edges = match obj_get(topology, "edges") {
Some(Json::Obj(p)) => p.clone(),
_ => vec![],
};
for i in 0..topics.len() {
for b in &topics[i + 1..] {
let a = match &topics[i] {
Json::Str(s) => s.clone(),
other => capability_scoring::pyjson::dumps(other),
};
let bkey = match b {
Json::Str(s) => s.clone(),
other => capability_scoring::pyjson::dumps(other),
};
let key = if a <= bkey {
format!("{}|{}", a, bkey)
} else {
format!("{}|{}", bkey, a)
};
let next = num_get(&edges, &key).unwrap_or(0.0) + 1.0;
upsert(&mut edges, &key, Json::Num(next));
}
}
upsert(&mut pairs, "edges", Json::Obj(edges));
upsert(&mut pairs, "seen", Json::Obj(seen));
Json::Obj(pairs)
}
/// Port of decay(): all edges lose DECAY_PER_TICK of their weight one
/// tick; edges that fall below 0.5 vanish (stale associations weaken).
pub fn decay(topology: &Json) -> Json {
let mut pairs = match topology {
Json::Obj(p) => p.clone(),
_ => vec![],
};
let kept: Vec<(String, Json)> = match obj_get(topology, "edges") {
Some(Json::Obj(p)) => p
.iter()
.filter_map(|(k, v)| match v {
Json::Num(n) => {
let d = n * DECAY_PER_TICK;
if d >= 0.5 {
Some((k.clone(), Json::Num(d)))
} else {
None
}
}
_ => None,
})
.collect(),
_ => vec![],
};
upsert(&mut pairs, "edges", Json::Obj(kept));
Json::Obj(pairs)
}
/// Port of clusters(): connected components over edges >= threshold.
/// Union-find mirrors the oracle exactly (union by second-root repoint,
/// first-seen iteration order); components come back biggest first,
/// members sorted. NOTE: the oracle's group order for equal-size
/// components is CPython set-iteration (hash) order — deliberately no
/// frozen case contains a size tie.
pub fn clusters(topology: &Json, threshold: f64) -> Vec<Json> {
let edges: Vec<(String, f64)> = match obj_get(topology, "edges") {
Some(Json::Obj(p)) => p
.iter()
.filter_map(|(k, v)| match v {
Json::Num(n) if *n >= threshold => Some((k.clone(), *n)),
_ => None,
})
.collect(),
_ => vec![],
};
let mut parent: Vec<(String, String)> = vec![];
fn find(parent: &[(String, String)], x: &str) -> String {
let mut cur = x.to_string();
loop {
match parent.iter().find(|(k, _)| *k == cur) {
Some((_, p)) if *p != cur => cur = p.clone(),
_ => return cur,
}
}
}
let mut nodes: Vec<String> = vec![];
for (k, _) in &edges {
let (a, b) = k.split_once('|').unwrap_or((k.as_str(), ""));
for n in [a.to_string(), b.to_string()] {
if !nodes.contains(&n) {
nodes.push(n);
}
}
let (ra, rb) = (find(&parent, a), find(&parent, b));
if ra != rb {
parent.push((rb, ra));
}
}
let mut groups: Vec<(String, Vec<String>)> = vec![];
for n in &nodes {
let root = find(&parent, n);
match groups.iter_mut().find(|(r, _)| *r == root) {
Some((_, members)) => members.push(n.clone()),
None => groups.push((root, vec![n.clone()])),
}
}
let mut out: Vec<Vec<String>> = groups.into_iter().map(|(_, g)| g).collect();
out.sort_by_key(|g| std::cmp::Reverse(g.len())); // stable: ties keep first-seen order
out.into_iter()
.map(|mut g| {
g.sort();
Json::Arr(g.into_iter().map(Json::Str).collect())
})
.collect()
}
/// Port of suggest(): the cluster a new fact belongs to — or a new topic.
pub fn suggest(topology: &Json, fact_topics: &[Json]) -> Json {
let cls = clusters(topology, CLUSTER_THRESHOLD);
let topic_set = |items: &[Json]| -> Vec<String> {
let mut v: Vec<String> = items
.iter()
.filter_map(|t| match t {
Json::Str(s) => Some(s.clone()),
_ => None,
})
.collect();
v.sort();
v.dedup();
v
};
let fact_set = topic_set(fact_topics);
let mut best: Option<Vec<String>> = None;
let mut best_overlap = 0usize;
for c in &cls {
let names: Vec<String> = match c {
Json::Arr(items) => items
.iter()
.filter_map(|x| match x {
Json::Str(s) => Some(s.clone()),
_ => None,
})
.collect(),
_ => vec![],
};
let overlap = names
.iter()
.filter(|n| fact_set.contains(n))
.count();
if overlap > best_overlap {
best_overlap = overlap;
best = Some(names);
}
}
match best {
None => Json::Obj(vec![
("suggestion".into(), Json::Str("new_topic".into())),
(
"reason".into(),
Json::Str("no cluster shares a topic with this fact".into()),
),
]),
Some(names) => Json::Obj(vec![
(
"suggestion".into(),
Json::Arr(names.into_iter().map(Json::Str).collect()),
),
(
"reason".into(),
Json::Str(format!("shares {} topic(s)", best_overlap)),
),
]),
}
}