cascade-mcp 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.
- cascade/__init__.py +7 -0
- cascade/cascade_routing.py +164 -0
- cascade/cascade_sim.py +331 -0
- cascade/server.py +386 -0
- cascade_mcp-0.1.0.dist-info/METADATA +152 -0
- cascade_mcp-0.1.0.dist-info/RECORD +9 -0
- cascade_mcp-0.1.0.dist-info/WHEEL +4 -0
- cascade_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- cascade_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
cascade/__init__.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cascade_routing.py — pure OCC vs pure cascade vs per-field HYBRID.
|
|
4
|
+
Fair head-to-head: forked/committed fields keep contending (no freeze), so
|
|
5
|
+
conflict volumes are comparable across policies. Tracks BOTH costs:
|
|
6
|
+
recomputes wasted expensive re-runs (OCC overpays these under churn)
|
|
7
|
+
silent_errors committed-but-actually-wrong values (cascade risks these)
|
|
8
|
+
|
|
9
|
+
Each field has a TRUE tolerance (drift its answer can absorb). Hybrid routes
|
|
10
|
+
zero-tolerance fields to OCC (safe) and tolerant fields to the semantic cascade
|
|
11
|
+
with materiality = measured tolerance. tol_safety>1 models OVER-estimating it.
|
|
12
|
+
fresh_loser_redo_prob models the assumption that a fresh loser re-runs anyway.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import math
|
|
16
|
+
import random
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from collections import Counter
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Field:
|
|
22
|
+
id: str; level: int; deps: list
|
|
23
|
+
rev: int = 0; value: float = 1.0
|
|
24
|
+
true_tol: float = 0.25; policy: str = "cascade"; materiality: float = 0.20
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Write:
|
|
28
|
+
tier: int; conf: float; read_rev: dict; read_val: dict
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Config:
|
|
32
|
+
n_levels: int = 3; fields_per_level: int = 6; deps_per_field: int = 3
|
|
33
|
+
conf_levels: tuple = (0.5, 0.7, 0.85, 0.95, 0.99)
|
|
34
|
+
rounds: int = 12000
|
|
35
|
+
source_write_prob: float = 0.25; contention_prob: float = 0.30
|
|
36
|
+
width: tuple = (2, 3); lag: int = 4; value_drift: float = 0.08
|
|
37
|
+
frac_zero_tol: float = 0.30; zero_tol: float = 0.01; gen_tol: float = 0.25
|
|
38
|
+
policy: str = "hybrid"; global_materiality: float = 0.20
|
|
39
|
+
route_threshold: float = 0.05; tol_safety: float = 1.0
|
|
40
|
+
# multiplicative log-normal noise on the per-field tolerance ESTIMATE.
|
|
41
|
+
# tol_safety is systematic bias; tol_est_noise is the honest "you measured
|
|
42
|
+
# it, but imperfectly" spread. At >0 the hybrid over-estimates on ~half its
|
|
43
|
+
# fields even with tol_safety=1 -> silent errors appear (the safety=1 zero
|
|
44
|
+
# is a perfect-knowledge artifact, not a property of the design).
|
|
45
|
+
tol_est_noise: float = 0.0
|
|
46
|
+
fresh_loser_redo_prob: float = 0.0; seed: int = 0
|
|
47
|
+
|
|
48
|
+
def drift(w, F):
|
|
49
|
+
return max((abs(F[d].value/v0 - 1.0) if v0 else 0.0 for d, v0 in w.read_val.items()), default=0.0)
|
|
50
|
+
def rev_stale(w, F):
|
|
51
|
+
return any(F[d].rev > s for d, s in w.read_rev.items())
|
|
52
|
+
def bump(f, rng, cfg):
|
|
53
|
+
f.rev += 1; f.value *= (1.0 + rng.gauss(0.0, cfg.value_drift))
|
|
54
|
+
|
|
55
|
+
def build(cfg, rng):
|
|
56
|
+
F, by = {}, []
|
|
57
|
+
for lvl in range(cfg.n_levels):
|
|
58
|
+
ids = [f"L{lvl}_{i}" for i in range(cfg.fields_per_level)]; by.append(ids)
|
|
59
|
+
for fid in ids:
|
|
60
|
+
deps = [] if lvl == 0 else rng.sample(
|
|
61
|
+
[x for p in by[:lvl] for x in p], min(cfg.deps_per_field, cfg.fields_per_level*lvl))
|
|
62
|
+
f = Field(fid, lvl, deps)
|
|
63
|
+
if lvl > 0:
|
|
64
|
+
f.true_tol = cfg.zero_tol if rng.random() < cfg.frac_zero_tol else cfg.gen_tol
|
|
65
|
+
measured = f.true_tol * cfg.tol_safety
|
|
66
|
+
if cfg.tol_est_noise > 0.0:
|
|
67
|
+
measured *= math.exp(rng.gauss(0.0, cfg.tol_est_noise))
|
|
68
|
+
if cfg.policy == "occ": f.policy = "occ"
|
|
69
|
+
# occ_value: OCC decision rule (commit-any-fresh, all losers
|
|
70
|
+
# rerun, no fork/authority) on the VALUE predicate. Isolates the
|
|
71
|
+
# staleness-predicate win from the routing/arbitration win.
|
|
72
|
+
elif cfg.policy == "occ_value":
|
|
73
|
+
f.policy, f.materiality = "occ_value", cfg.global_materiality
|
|
74
|
+
elif cfg.policy == "cascade": f.policy, f.materiality = "cascade", cfg.global_materiality
|
|
75
|
+
else:
|
|
76
|
+
if measured < cfg.route_threshold: f.policy = "occ"
|
|
77
|
+
else: f.policy, f.materiality = "cascade", measured
|
|
78
|
+
F[fid] = f
|
|
79
|
+
return F
|
|
80
|
+
|
|
81
|
+
def resolve(f, grp, F, cfg, rng):
|
|
82
|
+
if f.policy == "occ":
|
|
83
|
+
fresh = [w for w in grp if not rev_stale(w, F)]
|
|
84
|
+
if fresh: return True, len(grp) - 1, 0, "OCC_COMMIT"
|
|
85
|
+
return False, len(grp), 0, "OCC_ALLABORT"
|
|
86
|
+
if f.policy == "occ_value":
|
|
87
|
+
# value predicate + OCC accounting: commit one fresh, ALL losers rerun
|
|
88
|
+
# (no free adoption), no fork/authority. Can leak, because a flat global
|
|
89
|
+
# materiality has no idea of this field's true tolerance.
|
|
90
|
+
fresh = [w for w in grp if drift(w, F) <= f.materiality]
|
|
91
|
+
if not fresh: return False, len(grp), 0, "OCC_ALLABORT"
|
|
92
|
+
silent = 1 if drift(fresh[0], F) > f.true_tol else 0
|
|
93
|
+
return True, len(grp) - 1, silent, "OCC_COMMIT"
|
|
94
|
+
m = f.materiality
|
|
95
|
+
fresh = [w for w in grp if drift(w, F) <= m]; n_stale = len(grp) - len(fresh)
|
|
96
|
+
if not fresh: return False, len(grp), 0, "RECOMPUTE"
|
|
97
|
+
redo = n_stale + sum(1 for _ in range(len(fresh) - 1) if rng.random() < cfg.fresh_loser_redo_prob)
|
|
98
|
+
bt = min(w.tier for w in fresh); top = [w for w in fresh if w.tier == bt]
|
|
99
|
+
if len(top) > 1:
|
|
100
|
+
bc = max(w.conf for w in top); top = [w for w in top if w.conf == bc]
|
|
101
|
+
if len(top) > 1: return True, redo, 0, "FORK"
|
|
102
|
+
silent = 1 if drift(top[0], F) > f.true_tol else 0
|
|
103
|
+
return True, redo, silent, "WINNER"
|
|
104
|
+
|
|
105
|
+
def run(cfg):
|
|
106
|
+
rng = random.Random(cfg.seed); F = build(cfg, rng)
|
|
107
|
+
src = [f for f in F.values() if f.level == 0]; der = [f for f in F.values() if f.level > 0]
|
|
108
|
+
topo = sorted(F.values(), key=lambda f: f.level); pending, since, M = {}, {}, Counter()
|
|
109
|
+
for r in range(cfg.rounds):
|
|
110
|
+
for f in src:
|
|
111
|
+
if rng.random() < cfg.source_write_prob: bump(f, rng, cfg)
|
|
112
|
+
for f in der:
|
|
113
|
+
if f.id in pending: continue
|
|
114
|
+
if rng.random() < cfg.contention_prob:
|
|
115
|
+
grp = [Write(2, rng.choice(cfg.conf_levels),
|
|
116
|
+
{d: F[d].rev for d in f.deps}, {d: F[d].value for d in f.deps})
|
|
117
|
+
for _ in range(rng.randint(*cfg.width))]
|
|
118
|
+
pending[f.id] = grp; since[f.id] = r
|
|
119
|
+
for f in topo:
|
|
120
|
+
g = pending.get(f.id)
|
|
121
|
+
if g is None or r - since[f.id] < cfg.lag: continue
|
|
122
|
+
committed, redo, silent, arm = resolve(f, g, F, cfg, rng)
|
|
123
|
+
M["conflicts"] += 1; M["recomputes"] += redo; M["silent_errors"] += silent; M[arm] += 1
|
|
124
|
+
if committed: M["commits"] += 1; bump(f, rng, cfg) # keeps contending; no freeze
|
|
125
|
+
del pending[f.id]; del since[f.id]
|
|
126
|
+
return M
|
|
127
|
+
|
|
128
|
+
def rep(label, M):
|
|
129
|
+
c = M["conflicts"] or 1; cm = M["commits"] or 1
|
|
130
|
+
print(f" {label:<28} {M['recomputes']/c:4.2f} recompute/conflict "
|
|
131
|
+
f"{M['recomputes']/cm:5.2f} recompute/commit "
|
|
132
|
+
f"silent_err {M['silent_errors']:>4} conflicts {M['conflicts']:>6}")
|
|
133
|
+
|
|
134
|
+
def main():
|
|
135
|
+
print("=" * 104)
|
|
136
|
+
print("POLICY HEAD-TO-HEAD (lag=4; 30% price-like tol~0.01, 70% estimate-like tol~0.25)")
|
|
137
|
+
print("=" * 104)
|
|
138
|
+
print("\n[10] pure OCC(rev) vs OCC(value) vs pure CASCADE(0.20) vs HYBRID(routed)")
|
|
139
|
+
rep("pure OCC (rev-staleness)", run(Config(policy="occ")))
|
|
140
|
+
rep("OCC (value-staleness 0.20)", run(Config(policy="occ_value", global_materiality=0.20)))
|
|
141
|
+
rep("pure CASCADE (global 0.20)", run(Config(policy="cascade", global_materiality=0.20)))
|
|
142
|
+
rep("HYBRID (measured tol)", run(Config(policy="hybrid")))
|
|
143
|
+
print(" OCC(value) vs OCC(rev): the throughput gain that is JUST the predicate,")
|
|
144
|
+
print(" not the routing. OCC(value) silent_err > 0: the predicate alone is unsafe.")
|
|
145
|
+
|
|
146
|
+
print("\n[10b] HYBRID under NOISY (imperfect) tolerance measurement, tol_safety=1")
|
|
147
|
+
print(" the safety=1 zero-leak is a perfect-knowledge artifact; noise leaks:")
|
|
148
|
+
for nz in (0.0, 0.5, 1.0):
|
|
149
|
+
rep(f"hybrid tol_est_noise={nz:.1f}", run(Config(policy="hybrid", tol_est_noise=nz)))
|
|
150
|
+
|
|
151
|
+
print("\n[11] survives the 'fresh loser adopts winner free' assumption being switched off?")
|
|
152
|
+
for p in (0.0, 0.5, 1.0):
|
|
153
|
+
rep(f"hybrid redo_prob={p:.1f}", run(Config(policy="hybrid", fresh_loser_redo_prob=p)))
|
|
154
|
+
|
|
155
|
+
print("\n[12] over-estimating tolerance: PURE CASCADE (bites hard) vs HYBRID (only cascade-routed fields)")
|
|
156
|
+
for s in (1.0, 2.0, 5.0):
|
|
157
|
+
rep(f"cascade tol_safety={s:.0f}", run(Config(policy="cascade", global_materiality=0.20 * s)))
|
|
158
|
+
for s in (1.0, 2.0, 5.0):
|
|
159
|
+
rep(f"hybrid tol_safety={s:.0f}", run(Config(policy="hybrid", tol_safety=s)))
|
|
160
|
+
print(" Measure tolerance conservatively (safety<=1) -> silent_err stays 0.")
|
|
161
|
+
print("=" * 104)
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
main()
|
cascade/cascade_sim.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cascade_sim.py — go/no-go instrumentation for the read-set-validated,
|
|
4
|
+
provenance-weighted resolution cascade (the "agent-mesh" thesis).
|
|
5
|
+
|
|
6
|
+
It simulates concurrent agent writes over a configurable dependency DAG and
|
|
7
|
+
classifies every conflict-resolution event into one of three arms:
|
|
8
|
+
|
|
9
|
+
FRESH_WINNER a live (non-stale) write wins on authority->confidence.
|
|
10
|
+
Fully automatic, no re-run, no human. THIS is the value:
|
|
11
|
+
the case where you beat OCC (no retry) and CodeCRDT (no LLM).
|
|
12
|
+
|
|
13
|
+
FORK two+ fresh writes tie on (authority, confidence). Lossless:
|
|
14
|
+
you defer to a human/high-tier agent instead of silently
|
|
15
|
+
dropping. Better than silent corruption, but not hands-off.
|
|
16
|
+
|
|
17
|
+
RECOMPUTE every competing write is premise-stale (all-stale). There is
|
|
18
|
+
no correct value to pick -> you must re-run. Here you are NO
|
|
19
|
+
BETTER than S-Bus OCC. This arm is the thesis-eroder.
|
|
20
|
+
|
|
21
|
+
Decision rule of thumb:
|
|
22
|
+
- high FRESH_WINNER across realistic churn -> strong GO
|
|
23
|
+
- RECOMPUTE dominates once churn is realistic -> "just use OCC", NO-GO
|
|
24
|
+
- FORK meaningful only if confidence is coarse -> design implication
|
|
25
|
+
|
|
26
|
+
The resolve() function below mirrors the Rust resolve_field() exactly:
|
|
27
|
+
staleness gate -> authority tier -> confidence -> fork-on-tie.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
import random
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from collections import Counter
|
|
34
|
+
from typing import Optional
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ----------------------------- data model -----------------------------------
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Field:
|
|
41
|
+
id: str
|
|
42
|
+
level: int
|
|
43
|
+
deps: list[str]
|
|
44
|
+
rev: int = 0 # monotonic per-field revision, bumped on commit
|
|
45
|
+
value: float = 1.0 # semantic content; perturbed on every commit/churn
|
|
46
|
+
forked: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Write:
|
|
51
|
+
field_id: str
|
|
52
|
+
agent_id: int
|
|
53
|
+
authority_tier: int # lower = more authoritative (Tier 0 = human/verified)
|
|
54
|
+
confidence: float
|
|
55
|
+
read_set: dict[str, int] # dep_id -> dep.rev observed at read time
|
|
56
|
+
read_vals: dict[str, float] # dep_id -> dep.value observed at read time
|
|
57
|
+
issued_round: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ----------------------------- config ----------------------------------------
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Config:
|
|
64
|
+
# DAG shape
|
|
65
|
+
n_levels: int = 3
|
|
66
|
+
fields_per_level: int = 6
|
|
67
|
+
deps_per_field: int = 3 # read-set size for derived fields
|
|
68
|
+
|
|
69
|
+
# agents / authority
|
|
70
|
+
n_agents: int = 8
|
|
71
|
+
# P(tier). Tier 0 = human/verified source, Tier 1 = analyst, Tier 2 = swarm.
|
|
72
|
+
tier_probs: tuple[float, float, float] = (0.0, 0.0, 1.0) # homogeneous swarm
|
|
73
|
+
|
|
74
|
+
# confidence: a coarse discrete scale (realistic for LLM/scraper self-report).
|
|
75
|
+
# Set continuous=True to make exact ties vanish (fork arm collapses).
|
|
76
|
+
confidence_levels: tuple[float, ...] = (0.5, 0.7, 0.85, 0.95, 0.99)
|
|
77
|
+
continuous_confidence: bool = False
|
|
78
|
+
|
|
79
|
+
# dynamics
|
|
80
|
+
rounds: int = 4000
|
|
81
|
+
source_write_prob: float = 0.25 # P(a source field churns each round)
|
|
82
|
+
contention_prob: float = 0.30 # P(a derived field draws concurrent writes/round)
|
|
83
|
+
contention_width: tuple[int, int] = (2, 3) # inclusive range of concurrent writers
|
|
84
|
+
resolution_lag: int = 1 # rounds between read and resolve (time pressure)
|
|
85
|
+
|
|
86
|
+
# --- staleness model ---------------------------------------------------
|
|
87
|
+
# rev-staleness (default): a write is stale iff ANY dependency's rev moved.
|
|
88
|
+
# This is the harsh upper bound — it flags "HQ city updated" as
|
|
89
|
+
# invalidating a revenue estimate that never touched the HQ field's value.
|
|
90
|
+
# semantic-staleness: a write is stale iff a dependency's VALUE moved past
|
|
91
|
+
# `materiality` (relative). A rev can advance while the value drifts
|
|
92
|
+
# below threshold -> the write survives. This is CoAgent's "did the
|
|
93
|
+
# conflict actually invalidate my premise" test, made deterministic.
|
|
94
|
+
semantic_staleness: bool = False
|
|
95
|
+
materiality: float = 0.10 # relative value move that counts as stale
|
|
96
|
+
value_drift: float = 0.08 # std-dev of relative value move per event
|
|
97
|
+
|
|
98
|
+
seed: int = 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ----------------------------- core resolve ----------------------------------
|
|
102
|
+
# Mirrors Rust resolve_field(). Pure function of the competing writes + the
|
|
103
|
+
# already-resolved upstream revs (resolution happens in topological order, so
|
|
104
|
+
# every dependency is at its winning rev before this is called).
|
|
105
|
+
|
|
106
|
+
FRESH_WINNER = "FRESH_WINNER"
|
|
107
|
+
FORK = "FORK"
|
|
108
|
+
RECOMPUTE = "RECOMPUTE"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_stale(w: Write, fields: dict[str, Field], cfg: Config) -> bool:
|
|
112
|
+
if not cfg.semantic_staleness:
|
|
113
|
+
# rev-staleness: stale iff any dependency advanced past the recorded rev
|
|
114
|
+
return any(fields[dep].rev > seen for dep, seen in w.read_set.items())
|
|
115
|
+
# semantic-staleness: stale iff any dependency's VALUE moved past materiality
|
|
116
|
+
for dep, seen_val in w.read_vals.items():
|
|
117
|
+
now = fields[dep].value
|
|
118
|
+
base = abs(seen_val) or 1.0
|
|
119
|
+
if abs(now - seen_val) / base > cfg.materiality:
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve(writes: list[Write], fields: dict[str, Field],
|
|
125
|
+
cfg: Config) -> tuple[str, Optional[Write]]:
|
|
126
|
+
fresh = [w for w in writes if not is_stale(w, fields, cfg)]
|
|
127
|
+
|
|
128
|
+
if len(fresh) == 0:
|
|
129
|
+
return RECOMPUTE, None
|
|
130
|
+
if len(fresh) == 1:
|
|
131
|
+
return FRESH_WINNER, fresh[0]
|
|
132
|
+
|
|
133
|
+
# authority: lowest tier number wins
|
|
134
|
+
best_tier = min(w.authority_tier for w in fresh)
|
|
135
|
+
top = [w for w in fresh if w.authority_tier == best_tier]
|
|
136
|
+
if len(top) == 1:
|
|
137
|
+
return FRESH_WINNER, top[0]
|
|
138
|
+
|
|
139
|
+
# confidence: highest wins
|
|
140
|
+
best_conf = max(w.confidence for w in top)
|
|
141
|
+
winners = [w for w in top if w.confidence == best_conf]
|
|
142
|
+
if len(winners) == 1:
|
|
143
|
+
return FRESH_WINNER, winners[0]
|
|
144
|
+
|
|
145
|
+
# exact tie on (authority, confidence) -> lossless fork
|
|
146
|
+
return FORK, None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ----------------------------- simulation ------------------------------------
|
|
150
|
+
|
|
151
|
+
def build_dag(cfg: Config, rng: random.Random) -> dict[str, Field]:
|
|
152
|
+
fields: dict[str, Field] = {}
|
|
153
|
+
by_level: list[list[str]] = []
|
|
154
|
+
for lvl in range(cfg.n_levels):
|
|
155
|
+
ids = [f"L{lvl}_{i}" for i in range(cfg.fields_per_level)]
|
|
156
|
+
by_level.append(ids)
|
|
157
|
+
for fid in ids:
|
|
158
|
+
if lvl == 0:
|
|
159
|
+
deps: list[str] = []
|
|
160
|
+
else:
|
|
161
|
+
pool = [x for prev in by_level[:lvl] for x in prev]
|
|
162
|
+
k = min(cfg.deps_per_field, len(pool))
|
|
163
|
+
deps = rng.sample(pool, k)
|
|
164
|
+
fields[fid] = Field(id=fid, level=lvl, deps=deps)
|
|
165
|
+
return fields
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def draw_tier(cfg: Config, rng: random.Random) -> int:
|
|
169
|
+
r, acc = rng.random(), 0.0
|
|
170
|
+
for tier, p in enumerate(cfg.tier_probs):
|
|
171
|
+
acc += p
|
|
172
|
+
if r <= acc:
|
|
173
|
+
return tier
|
|
174
|
+
return len(cfg.tier_probs) - 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def draw_conf(cfg: Config, rng: random.Random) -> float:
|
|
178
|
+
if cfg.continuous_confidence:
|
|
179
|
+
return rng.random()
|
|
180
|
+
return rng.choice(cfg.confidence_levels)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def perturb(f: Field, cfg: Config, rng: random.Random) -> None:
|
|
184
|
+
# a commit/churn moves the field's value by a random relative amount.
|
|
185
|
+
# some events move it a lot (material), some barely (immaterial) — which
|
|
186
|
+
# is exactly what semantic-staleness gets to distinguish and rev-staleness
|
|
187
|
+
# cannot. Multiplicative so value never hits 0 (read_vals stay well-defined).
|
|
188
|
+
f.value *= (1.0 + rng.gauss(0.0, cfg.value_drift))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run(cfg: Config) -> Counter:
|
|
192
|
+
rng = random.Random(cfg.seed)
|
|
193
|
+
fields = build_dag(cfg, rng)
|
|
194
|
+
sources = [f for f in fields.values() if f.level == 0]
|
|
195
|
+
derived = [f for f in fields.values() if f.level > 0]
|
|
196
|
+
topo = sorted(fields.values(), key=lambda f: f.level)
|
|
197
|
+
|
|
198
|
+
pending: dict[str, list[Write]] = {} # field_id -> concurrent writes
|
|
199
|
+
pending_since: dict[str, int] = {}
|
|
200
|
+
arms: Counter = Counter()
|
|
201
|
+
|
|
202
|
+
for r in range(cfg.rounds):
|
|
203
|
+
# (a) source churn — single accepted write, bumps rev (not a conflict)
|
|
204
|
+
for f in sources:
|
|
205
|
+
if rng.random() < cfg.source_write_prob:
|
|
206
|
+
f.rev += 1
|
|
207
|
+
perturb(f, cfg, rng)
|
|
208
|
+
|
|
209
|
+
# (b) issue contention on derived fields not already contended
|
|
210
|
+
for f in derived:
|
|
211
|
+
if f.id in pending or f.forked:
|
|
212
|
+
continue
|
|
213
|
+
if rng.random() < cfg.contention_prob:
|
|
214
|
+
w = rng.randint(*cfg.contention_width)
|
|
215
|
+
writes = []
|
|
216
|
+
for _ in range(w):
|
|
217
|
+
read_set = {d: fields[d].rev for d in f.deps}
|
|
218
|
+
read_vals = {d: fields[d].value for d in f.deps}
|
|
219
|
+
writes.append(Write(
|
|
220
|
+
field_id=f.id,
|
|
221
|
+
agent_id=rng.randrange(cfg.n_agents),
|
|
222
|
+
authority_tier=draw_tier(cfg, rng),
|
|
223
|
+
confidence=draw_conf(cfg, rng),
|
|
224
|
+
read_set=read_set,
|
|
225
|
+
read_vals=read_vals,
|
|
226
|
+
issued_round=r,
|
|
227
|
+
))
|
|
228
|
+
pending[f.id] = writes
|
|
229
|
+
pending_since[f.id] = r
|
|
230
|
+
|
|
231
|
+
# (c) resolve matured groups in topological order (upstream first, so an
|
|
232
|
+
# upstream commit this round can invalidate a downstream group)
|
|
233
|
+
for f in topo:
|
|
234
|
+
grp = pending.get(f.id)
|
|
235
|
+
if grp is None:
|
|
236
|
+
continue
|
|
237
|
+
if r - pending_since[f.id] < cfg.resolution_lag:
|
|
238
|
+
continue
|
|
239
|
+
arm, winner = resolve(grp, fields, cfg)
|
|
240
|
+
arms[arm] += 1
|
|
241
|
+
if arm == FRESH_WINNER:
|
|
242
|
+
f.rev += 1 # commit winner, bump rev (cascades)
|
|
243
|
+
perturb(f, cfg, rng) # ...and move value (may be immaterial)
|
|
244
|
+
elif arm == FORK:
|
|
245
|
+
f.rev += 1
|
|
246
|
+
perturb(f, cfg, rng)
|
|
247
|
+
f.forked = True # deferred; freeze for this run
|
|
248
|
+
# RECOMPUTE: nothing committed (losers would re-run)
|
|
249
|
+
del pending[f.id]
|
|
250
|
+
del pending_since[f.id]
|
|
251
|
+
|
|
252
|
+
return arms
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def summarize(arms: Counter) -> dict[str, float]:
|
|
256
|
+
total = sum(arms.values()) or 1
|
|
257
|
+
return {k: 100.0 * arms.get(k, 0) / total for k in (FRESH_WINNER, FORK, RECOMPUTE)}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def pct_line(label: str, arms: Counter) -> str:
|
|
261
|
+
s = summarize(arms)
|
|
262
|
+
total = sum(arms.values())
|
|
263
|
+
return (f"{label:<28} "
|
|
264
|
+
f"winner {s[FRESH_WINNER]:5.1f}% "
|
|
265
|
+
f"fork {s[FORK]:5.1f}% "
|
|
266
|
+
f"recompute {s[RECOMPUTE]:5.1f}% "
|
|
267
|
+
f"(n={total})")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ----------------------------- experiments -----------------------------------
|
|
271
|
+
|
|
272
|
+
def main() -> None:
|
|
273
|
+
print("=" * 92)
|
|
274
|
+
print("CASCADE GO/NO-GO — resolution-arm distribution")
|
|
275
|
+
print("winner = automatic win (beats OCC+CodeCRDT) | fork = lossless defer | "
|
|
276
|
+
"recompute = no better than OCC")
|
|
277
|
+
print("=" * 92)
|
|
278
|
+
|
|
279
|
+
base = Config()
|
|
280
|
+
print("\n[1] BASELINE (homogeneous Tier-2 swarm, coarse confidence, lag=1)")
|
|
281
|
+
print(" " + pct_line("baseline", run(base)))
|
|
282
|
+
|
|
283
|
+
print("\n[2] CHURN / TIME-PRESSURE SWEEP (resolution_lag = rounds between read and resolve)")
|
|
284
|
+
print(" higher lag = more upstream churn lands in the read->resolve window")
|
|
285
|
+
for lag in (0, 1, 2, 4, 8):
|
|
286
|
+
cfg = Config(resolution_lag=lag)
|
|
287
|
+
print(" " + pct_line(f"lag={lag}", run(cfg)))
|
|
288
|
+
|
|
289
|
+
print("\n[3] SOURCE-CHURN SWEEP (P a source field changes each round; lag=2)")
|
|
290
|
+
for p in (0.05, 0.15, 0.30, 0.50, 0.75):
|
|
291
|
+
cfg = Config(source_write_prob=p, resolution_lag=2)
|
|
292
|
+
print(" " + pct_line(f"source_write_prob={p:.2f}", run(cfg)))
|
|
293
|
+
|
|
294
|
+
print("\n[4] CONFIDENCE GRANULARITY (does the FORK arm survive real-valued confidence?)")
|
|
295
|
+
print(" " + pct_line("coarse 5-level", run(Config())))
|
|
296
|
+
print(" " + pct_line("continuous float", run(Config(continuous_confidence=True)))
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
print("\n[5] AUTHORITY DIVERSITY (homogeneous swarm vs. mixed tiers w/ occasional human)")
|
|
300
|
+
print(" " + pct_line("homogeneous (all Tier2)", run(Config(tier_probs=(0.0, 0.0, 1.0)))))
|
|
301
|
+
print(" " + pct_line("some analysts (T1/T2)", run(Config(tier_probs=(0.0, 0.35, 0.65)))))
|
|
302
|
+
print(" " + pct_line("rare human (T0/T1/T2)", run(Config(tier_probs=(0.1, 0.3, 0.6)))))
|
|
303
|
+
|
|
304
|
+
print("\n[6] DEPENDENCY WIDTH (read-set size; more deps = more staleness surface, lag=2)")
|
|
305
|
+
for d in (1, 2, 4, 6):
|
|
306
|
+
cfg = Config(deps_per_field=d, resolution_lag=2)
|
|
307
|
+
print(" " + pct_line(f"deps_per_field={d}", run(cfg)))
|
|
308
|
+
|
|
309
|
+
print("\n[7] SEMANTIC STALENESS (does 'value moved' beat 'rev moved'? the thesis test)")
|
|
310
|
+
print(" rev-staleness flags ANY upstream change; semantic clears immaterial ones.")
|
|
311
|
+
print(" -- materiality sweep (lag=4, where rev-staleness had 94% recompute) --")
|
|
312
|
+
print(" " + pct_line("rev-staleness (baseline)",
|
|
313
|
+
run(Config(resolution_lag=4))))
|
|
314
|
+
for m in (0.05, 0.10, 0.20, 0.40):
|
|
315
|
+
cfg = Config(resolution_lag=4, semantic_staleness=True, materiality=m)
|
|
316
|
+
print(" " + pct_line(f"semantic materiality={m:.2f}", run(cfg)))
|
|
317
|
+
print(" -- same lag sweep, semantic mode (compare to [2]) --")
|
|
318
|
+
for lag in (0, 1, 2, 4, 8):
|
|
319
|
+
cfg = Config(resolution_lag=lag, semantic_staleness=True, materiality=0.20)
|
|
320
|
+
print(" " + pct_line(f"semantic lag={lag}", run(cfg)))
|
|
321
|
+
|
|
322
|
+
print("\n" + "=" * 92)
|
|
323
|
+
print("Read the FRESH_WINNER column against your expected deployment churn.")
|
|
324
|
+
print("If winner stays high where your real churn lives -> GO.")
|
|
325
|
+
print("If recompute swamps it there -> the DAG machinery")
|
|
326
|
+
print("buys little over plain OCC; ship OCC instead.")
|
|
327
|
+
print("=" * 92)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
if __name__ == "__main__":
|
|
331
|
+
main()
|
cascade/server.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
server.py — MCP server that wraps cascade_routing.py as a concurrency
|
|
4
|
+
controller for AI agents. Two main tools:
|
|
5
|
+
|
|
6
|
+
read_state(fields, agent_id)
|
|
7
|
+
Returns current value+rev of the requested fields and secretly logs
|
|
8
|
+
them as that agent's read-set (the rev/value snapshot the agent saw).
|
|
9
|
+
|
|
10
|
+
propose_update(field, proposed_value, confidence, authority_tier,
|
|
11
|
+
tolerance, agent_id, expected_writers)
|
|
12
|
+
Records the agent's proposed write against their logged read-set. When
|
|
13
|
+
the batch for `field` reaches `expected_writers`, runs the hybrid
|
|
14
|
+
resolve_field router (cr.resolve) on the whole batch, commits/bumps on
|
|
15
|
+
a winning arm, and returns the outcome. Earlier calls in the batch
|
|
16
|
+
return {"status": "pending"}; the final call returns the full result.
|
|
17
|
+
|
|
18
|
+
A configure tool builds the dependency DAG with per-field tolerance/policy
|
|
19
|
+
(mirrors cr.build). The resolver is called directly on cr.resolve/cr.bump so
|
|
20
|
+
outcomes are identical to in-process simulation.
|
|
21
|
+
|
|
22
|
+
The tool business logic lives in async *_impl functions so it can be unit-
|
|
23
|
+
tested in-process without the stdio transport; the @mcp decorators are thin
|
|
24
|
+
adapters that JSON-(de)serialize and call the impls.
|
|
25
|
+
|
|
26
|
+
Run as a stdio MCP server: python -m cascade.server
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
import json
|
|
30
|
+
import math
|
|
31
|
+
import os
|
|
32
|
+
import random
|
|
33
|
+
import sys
|
|
34
|
+
from dataclasses import asdict, replace
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import mcp.types as types
|
|
38
|
+
from mcp.server import Server
|
|
39
|
+
from mcp.server.stdio import stdio_server
|
|
40
|
+
|
|
41
|
+
from cascade import cascade_routing as cr
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ----------------------------- shared state ----------------------------------
|
|
45
|
+
# Module-global so the stdio server and in-process tests share one engine.
|
|
46
|
+
|
|
47
|
+
class CascadeState:
|
|
48
|
+
"""Holds the field store, per-agent read-set log, and pending batches."""
|
|
49
|
+
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
self.fields: dict[str, cr.Field] = {}
|
|
52
|
+
# (agent_id, field_id_being_written) -> {dep_id: rev}
|
|
53
|
+
self.read_rev: dict[tuple[str, str], dict[str, int]] = {}
|
|
54
|
+
# (agent_id, field_id_being_written) -> {dep_id: value}
|
|
55
|
+
self.read_val: dict[tuple[str, str], dict[str, float]] = {}
|
|
56
|
+
self.pending: dict[str, list[cr.Write]] = {}
|
|
57
|
+
# regime-level config (set by configure)
|
|
58
|
+
self.tol_safety: float = 1.0
|
|
59
|
+
self.route_threshold: float = 0.05
|
|
60
|
+
self.value_drift: float = 0.08
|
|
61
|
+
self.fresh_loser_redo_prob: float = 0.0
|
|
62
|
+
self.n_levels = 3
|
|
63
|
+
self.fields_per_level = 6
|
|
64
|
+
self.deps_per_field = 3
|
|
65
|
+
self.frac_zero_tol = 0.30
|
|
66
|
+
self.zero_tol = 0.01
|
|
67
|
+
self.gen_tol = 0.25
|
|
68
|
+
# policy_mode set by configure; per-write re-routing only happens
|
|
69
|
+
# in "hybrid" mode (mirrors cr.build). "occ" and "cascade" fix the
|
|
70
|
+
# field's policy at configure time and propose_update must NOT clobber it.
|
|
71
|
+
self.policy_mode: str = "hybrid"
|
|
72
|
+
self.global_materiality: float = 0.20
|
|
73
|
+
# multiplicative log-normal noise on tolerance ESTIMATE (mirrors
|
|
74
|
+
# cr.Config.tol_est_noise). >0 -> hybrid over-estimates on ~half its
|
|
75
|
+
# fields even at tol_safety=1 -> silent errors (perfect-knowledge
|
|
76
|
+
# artifact test).
|
|
77
|
+
self.tol_est_noise: float = 0.0
|
|
78
|
+
self.rng: random.Random = random.Random(0)
|
|
79
|
+
|
|
80
|
+
def reset(self) -> None:
|
|
81
|
+
self.fields.clear()
|
|
82
|
+
self.read_rev.clear()
|
|
83
|
+
self.read_val.clear()
|
|
84
|
+
self.pending.clear()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
state = CascadeState()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ----------------------------- tool implementations --------------------------
|
|
91
|
+
# Pure async functions: take parsed python args, return python dicts.
|
|
92
|
+
# The MCP decorators below adapt these to JSON wire types.
|
|
93
|
+
|
|
94
|
+
async def configure_impl(
|
|
95
|
+
n_levels: int, fields_per_level: int, deps_per_field: int,
|
|
96
|
+
frac_zero_tol: float, zero_tol: float, gen_tol: float,
|
|
97
|
+
tol_safety: float, route_threshold: float, value_drift: float,
|
|
98
|
+
fresh_loser_redo_prob: float, seed: int,
|
|
99
|
+
policy_mode: str = "hybrid", global_materiality: float = 0.20,
|
|
100
|
+
tol_est_noise: float = 0.0,
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""Build the DAG exactly like cr.build, then store regime config."""
|
|
103
|
+
state.rng = random.Random(seed)
|
|
104
|
+
state.n_levels = n_levels
|
|
105
|
+
state.fields_per_level = fields_per_level
|
|
106
|
+
state.deps_per_field = deps_per_field
|
|
107
|
+
state.frac_zero_tol = frac_zero_tol
|
|
108
|
+
state.zero_tol = zero_tol
|
|
109
|
+
state.gen_tol = gen_tol
|
|
110
|
+
state.tol_safety = tol_safety
|
|
111
|
+
state.route_threshold = route_threshold
|
|
112
|
+
state.value_drift = value_drift
|
|
113
|
+
state.fresh_loser_redo_prob = fresh_loser_redo_prob
|
|
114
|
+
state.policy_mode = policy_mode
|
|
115
|
+
state.global_materiality = global_materiality
|
|
116
|
+
state.tol_est_noise = tol_est_noise
|
|
117
|
+
state.reset()
|
|
118
|
+
# build DAG (mirrors cr.build)
|
|
119
|
+
by: list[list[str]] = []
|
|
120
|
+
for lvl in range(n_levels):
|
|
121
|
+
ids = [f"L{lvl}_{i}" for i in range(fields_per_level)]
|
|
122
|
+
by.append(ids)
|
|
123
|
+
for fid in ids:
|
|
124
|
+
if lvl == 0:
|
|
125
|
+
deps: list[str] = []
|
|
126
|
+
else:
|
|
127
|
+
pool = [x for p in by[:lvl] for x in p]
|
|
128
|
+
deps = state.rng.sample(pool, min(deps_per_field, len(pool)))
|
|
129
|
+
f = cr.Field(id=fid, level=lvl, deps=deps)
|
|
130
|
+
if lvl > 0:
|
|
131
|
+
f.true_tol = zero_tol if state.rng.random() < frac_zero_tol else gen_tol
|
|
132
|
+
measured = f.true_tol * tol_safety
|
|
133
|
+
if tol_est_noise > 0.0:
|
|
134
|
+
measured *= math.exp(state.rng.gauss(0.0, tol_est_noise))
|
|
135
|
+
if policy_mode == "occ":
|
|
136
|
+
f.policy = "occ"
|
|
137
|
+
elif policy_mode == "occ_value":
|
|
138
|
+
f.policy = "occ_value"
|
|
139
|
+
f.materiality = global_materiality
|
|
140
|
+
elif policy_mode == "cascade":
|
|
141
|
+
f.policy = "cascade"
|
|
142
|
+
f.materiality = global_materiality
|
|
143
|
+
else: # hybrid
|
|
144
|
+
if measured < route_threshold:
|
|
145
|
+
f.policy = "occ"
|
|
146
|
+
else:
|
|
147
|
+
f.policy = "cascade"
|
|
148
|
+
f.materiality = measured
|
|
149
|
+
state.fields[fid] = f
|
|
150
|
+
return {"status": "configured", "fields": list(state.fields.keys())}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def read_state_impl(fields: list[str], agent_id: str,
|
|
154
|
+
write_field: str = "") -> dict:
|
|
155
|
+
"""Return current value+rev of requested fields; secretly log them as the
|
|
156
|
+
caller's read-set keyed by (agent_id, write_field). write_field is the
|
|
157
|
+
field the agent intends to propose later (its deps are what's read)."""
|
|
158
|
+
snapshot = {}
|
|
159
|
+
log_rev: dict[str, int] = {}
|
|
160
|
+
log_val: dict[str, float] = {}
|
|
161
|
+
for fid in fields:
|
|
162
|
+
f = state.fields[fid]
|
|
163
|
+
snapshot[fid] = {"rev": f.rev, "value": f.value}
|
|
164
|
+
log_rev[fid] = f.rev
|
|
165
|
+
log_val[fid] = f.value
|
|
166
|
+
if write_field:
|
|
167
|
+
state.read_rev[(agent_id, write_field)] = log_rev
|
|
168
|
+
state.read_val[(agent_id, write_field)] = log_val
|
|
169
|
+
return {"fields": snapshot}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def churn_impl(field: str) -> dict:
|
|
173
|
+
"""Source-field churn: bump rev+value (mirrors the sim's source churn)."""
|
|
174
|
+
f = state.fields[field]
|
|
175
|
+
f.rev += 1
|
|
176
|
+
f.value *= (1.0 + state.rng.gauss(0.0, state.value_drift))
|
|
177
|
+
return {"field": field, "rev": f.rev, "value": f.value}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def propose_update_impl(
|
|
181
|
+
field: str, proposed_value: Any, confidence: float,
|
|
182
|
+
authority_tier: int, tolerance: float, agent_id: str,
|
|
183
|
+
expected_writers: int,
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Record a proposed write. When the batch reaches expected_writers, run
|
|
186
|
+
cr.resolve on the whole batch and return the outcome. Uses `tolerance` as
|
|
187
|
+
the field's true_tol for this write and re-derives policy/materiality from
|
|
188
|
+
it (mirrors cr.build's hybrid branch), so callers passing the field's
|
|
189
|
+
constant true_tol reproduce the sim exactly."""
|
|
190
|
+
f = state.fields[field]
|
|
191
|
+
# look up the agent's logged read-set for this field
|
|
192
|
+
read_rev = state.read_rev.get((agent_id, field), {})
|
|
193
|
+
read_val = state.read_val.get((agent_id, field), {})
|
|
194
|
+
# if no logged read-set, snapshot current (agent didn't call read_state)
|
|
195
|
+
if not read_rev:
|
|
196
|
+
read_rev = {d: state.fields[d].rev for d in f.deps}
|
|
197
|
+
read_val = {d: state.fields[d].value for d in f.deps}
|
|
198
|
+
w = cr.Write(tier=authority_tier, conf=confidence,
|
|
199
|
+
read_rev=dict(read_rev), read_val=dict(read_val))
|
|
200
|
+
state.pending.setdefault(field, []).append(w)
|
|
201
|
+
batch = state.pending[field]
|
|
202
|
+
if len(batch) < expected_writers:
|
|
203
|
+
return {"status": "pending", "field": field,
|
|
204
|
+
"received": len(batch), "expected": expected_writers}
|
|
205
|
+
# batch complete -> resolve
|
|
206
|
+
# Set the field's true_tol from the caller-declared tolerance. Re-derive
|
|
207
|
+
# routing ONLY in hybrid mode AND only if the caller's tolerance differs
|
|
208
|
+
# from what configure_impl already set (mirrors cr.build's hybrid branch).
|
|
209
|
+
# In occ/cascade modes the policy/materiality were fixed at configure time
|
|
210
|
+
# and must NOT be clobbered per-write. Re-deriving when tolerance is
|
|
211
|
+
# unchanged would also wipe any tol_est_noise applied at build time.
|
|
212
|
+
if abs(f.true_tol - tolerance) > 1e-12:
|
|
213
|
+
f.true_tol = tolerance
|
|
214
|
+
if state.policy_mode == "hybrid":
|
|
215
|
+
measured = tolerance * state.tol_safety
|
|
216
|
+
if state.tol_est_noise > 0.0:
|
|
217
|
+
measured *= math.exp(state.rng.gauss(0.0, state.tol_est_noise))
|
|
218
|
+
if measured < state.route_threshold:
|
|
219
|
+
f.policy = "occ"
|
|
220
|
+
else:
|
|
221
|
+
f.policy = "cascade"
|
|
222
|
+
f.materiality = measured
|
|
223
|
+
cfg = cr.Config(value_drift=state.value_drift,
|
|
224
|
+
fresh_loser_redo_prob=state.fresh_loser_redo_prob,
|
|
225
|
+
tol_safety=state.tol_safety,
|
|
226
|
+
tol_est_noise=state.tol_est_noise,
|
|
227
|
+
route_threshold=state.route_threshold)
|
|
228
|
+
committed, redo, silent, arm = cr.resolve(f, batch, state.fields, cfg, state.rng)
|
|
229
|
+
# staleness accounting at resolve time (for the CSV). occ uses rev-stale;
|
|
230
|
+
# occ_value, cascade, and hybrid all use the value-predicate drift.
|
|
231
|
+
if f.policy == "occ":
|
|
232
|
+
n_stale = sum(1 for ww in batch if cr.rev_stale(ww, state.fields))
|
|
233
|
+
else:
|
|
234
|
+
n_stale = sum(1 for ww in batch if cr.drift(ww, state.fields) > f.materiality)
|
|
235
|
+
# winner tier/conf
|
|
236
|
+
if arm in ("WINNER", "FORK", "OCC_COMMIT"):
|
|
237
|
+
fresh = [ww for ww in batch
|
|
238
|
+
if (not cr.rev_stale(ww, state.fields) if f.policy == "occ"
|
|
239
|
+
else cr.drift(ww, state.fields) <= f.materiality)]
|
|
240
|
+
bt = min(ww.tier for ww in fresh)
|
|
241
|
+
top = [ww for ww in fresh if ww.tier == bt]
|
|
242
|
+
bc = max(ww.conf for ww in top)
|
|
243
|
+
top = [ww for ww in top if ww.conf == bc]
|
|
244
|
+
win_tier = top[0].tier
|
|
245
|
+
top_conf = top[0].conf
|
|
246
|
+
else:
|
|
247
|
+
win_tier = -1
|
|
248
|
+
top_conf = -1.0
|
|
249
|
+
if committed:
|
|
250
|
+
cr.bump(f, state.rng, cfg)
|
|
251
|
+
del state.pending[field]
|
|
252
|
+
return {
|
|
253
|
+
"status": "resolved", "field": field, "arm": arm,
|
|
254
|
+
"recomputes": redo, "silent_error": silent,
|
|
255
|
+
"committed": committed, "n_writers": len(batch), "n_stale": n_stale,
|
|
256
|
+
"win_tier": win_tier, "top_confidence": top_conf,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def get_field_impl(field: str) -> dict:
|
|
261
|
+
"""Inspect a field's current state (for tests/debugging)."""
|
|
262
|
+
f = state.fields[field]
|
|
263
|
+
return {"id": f.id, "level": f.level, "deps": f.deps, "rev": f.rev,
|
|
264
|
+
"value": f.value, "true_tol": f.true_tol,
|
|
265
|
+
"policy": f.policy, "materiality": f.materiality}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ----------------------------- MCP server wiring -----------------------------
|
|
269
|
+
|
|
270
|
+
server = Server("cascade-routing-controller")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@server.list_tools()
|
|
274
|
+
async def list_tools() -> list[types.Tool]:
|
|
275
|
+
return [
|
|
276
|
+
types.Tool(
|
|
277
|
+
name="configure",
|
|
278
|
+
description="Build the dependency DAG and set regime parameters.",
|
|
279
|
+
inputSchema={
|
|
280
|
+
"type": "object",
|
|
281
|
+
"properties": {
|
|
282
|
+
"n_levels": {"type": "integer"},
|
|
283
|
+
"fields_per_level": {"type": "integer"},
|
|
284
|
+
"deps_per_field": {"type": "integer"},
|
|
285
|
+
"frac_zero_tol": {"type": "number"},
|
|
286
|
+
"zero_tol": {"type": "number"},
|
|
287
|
+
"gen_tol": {"type": "number"},
|
|
288
|
+
"tol_safety": {"type": "number"},
|
|
289
|
+
"route_threshold": {"type": "number"},
|
|
290
|
+
"value_drift": {"type": "number"},
|
|
291
|
+
"fresh_loser_redo_prob": {"type": "number"},
|
|
292
|
+
"seed": {"type": "integer"},
|
|
293
|
+
"policy_mode": {"type": "string"},
|
|
294
|
+
"global_materiality": {"type": "number"},
|
|
295
|
+
},
|
|
296
|
+
"required": ["n_levels", "fields_per_level", "deps_per_field",
|
|
297
|
+
"frac_zero_tol", "zero_tol", "gen_tol",
|
|
298
|
+
"tol_safety", "route_threshold", "value_drift",
|
|
299
|
+
"fresh_loser_redo_prob", "seed"],
|
|
300
|
+
},
|
|
301
|
+
),
|
|
302
|
+
types.Tool(
|
|
303
|
+
name="read_state",
|
|
304
|
+
description="Read current value+rev of fields and log them as the "
|
|
305
|
+
"caller's read-set (the premise snapshot for a later write).",
|
|
306
|
+
inputSchema={
|
|
307
|
+
"type": "object",
|
|
308
|
+
"properties": {
|
|
309
|
+
"fields": {"type": "array", "items": {"type": "string"}},
|
|
310
|
+
"agent_id": {"type": "string"},
|
|
311
|
+
"write_field": {"type": "string"},
|
|
312
|
+
},
|
|
313
|
+
"required": ["fields", "agent_id", "write_field"],
|
|
314
|
+
},
|
|
315
|
+
),
|
|
316
|
+
types.Tool(
|
|
317
|
+
name="propose_update",
|
|
318
|
+
description="Propose a write. When the batch for `field` reaches "
|
|
319
|
+
"`expected_writers`, runs the hybrid resolve_field router.",
|
|
320
|
+
inputSchema={
|
|
321
|
+
"type": "object",
|
|
322
|
+
"properties": {
|
|
323
|
+
"field": {"type": "string"},
|
|
324
|
+
"proposed_value": {},
|
|
325
|
+
"confidence": {"type": "number"},
|
|
326
|
+
"authority_tier": {"type": "integer"},
|
|
327
|
+
"tolerance": {"type": "number"},
|
|
328
|
+
"agent_id": {"type": "string"},
|
|
329
|
+
"expected_writers": {"type": "integer"},
|
|
330
|
+
},
|
|
331
|
+
"required": ["field", "proposed_value", "confidence",
|
|
332
|
+
"authority_tier", "tolerance", "agent_id",
|
|
333
|
+
"expected_writers"],
|
|
334
|
+
},
|
|
335
|
+
),
|
|
336
|
+
types.Tool(
|
|
337
|
+
name="churn",
|
|
338
|
+
description="Bump a source field's rev+value (simulates upstream churn).",
|
|
339
|
+
inputSchema={
|
|
340
|
+
"type": "object",
|
|
341
|
+
"properties": {"field": {"type": "string"}},
|
|
342
|
+
"required": ["field"],
|
|
343
|
+
},
|
|
344
|
+
),
|
|
345
|
+
types.Tool(
|
|
346
|
+
name="get_field",
|
|
347
|
+
description="Inspect a field's current state.",
|
|
348
|
+
inputSchema={
|
|
349
|
+
"type": "object",
|
|
350
|
+
"properties": {"field": {"type": "string"}},
|
|
351
|
+
"required": ["field"],
|
|
352
|
+
},
|
|
353
|
+
),
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@server.call_tool()
|
|
358
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
359
|
+
if name == "configure":
|
|
360
|
+
r = await configure_impl(**arguments)
|
|
361
|
+
elif name == "read_state":
|
|
362
|
+
r = await read_state_impl(**arguments)
|
|
363
|
+
elif name == "propose_update":
|
|
364
|
+
r = await propose_update_impl(**arguments)
|
|
365
|
+
elif name == "churn":
|
|
366
|
+
r = await churn_impl(**arguments)
|
|
367
|
+
elif name == "get_field":
|
|
368
|
+
r = await get_field_impl(**arguments)
|
|
369
|
+
else:
|
|
370
|
+
raise ValueError(f"unknown tool: {name}")
|
|
371
|
+
return [types.TextContent(type="text", text=json.dumps(r, default=str))]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def main() -> None:
|
|
375
|
+
async with stdio_server() as (read, write):
|
|
376
|
+
await server.run(read, write, server.create_initialization_options())
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def cli() -> None:
|
|
380
|
+
"""Synchronous entry point for the ``cascade-mcp`` console script."""
|
|
381
|
+
import asyncio
|
|
382
|
+
asyncio.run(main())
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == "__main__":
|
|
386
|
+
cli()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cascade-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cascade-resolution routing for concurrent multi-agent writes, exposed as an MCP server.
|
|
5
|
+
Project-URL: Homepage, https://github.com/clemente-turrubiates/cascade-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/clemente-turrubiates/cascade-mcp
|
|
7
|
+
Author: Pedro Clemente-Turrubiates
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Pedro Clemente-Turrubiates
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: agents,conflict-resolution,mcp,model-context-protocol,occ,routing
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
35
|
+
Requires-Python: >=3.10
|
|
36
|
+
Requires-Dist: mcp>=1.0
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# cascade-mcp
|
|
40
|
+
|
|
41
|
+
[](https://pypi.org/project/cascade-mcp/)
|
|
42
|
+
[](https://pypi.org/project/cascade-mcp/)
|
|
43
|
+
[](https://github.com/clemente-turrubiates/cascade-mcp/actions/workflows/ci.yml)
|
|
44
|
+
[](LICENSE)
|
|
45
|
+
|
|
46
|
+
Cascade-resolution routing for concurrent multi-agent writes — a resolution
|
|
47
|
+
router that decides, per conflict, whether a write **wins**, **forks** to a
|
|
48
|
+
human, or must be **recomputed**, plus an MCP server that exposes the router as
|
|
49
|
+
tools and a stress-test suite that proves the behavior can't be cherry-picked.
|
|
50
|
+
|
|
51
|
+
## What's here
|
|
52
|
+
|
|
53
|
+
The core question: when many agents write to the same field over a dependency
|
|
54
|
+
DAG, how do you resolve conflicts without either silently committing wrong
|
|
55
|
+
values (pure cascade) or overpaying in wasted re-runs (pure OCC)? The **hybrid**
|
|
56
|
+
policy routes zero-tolerance fields to OCC and tolerant fields to a
|
|
57
|
+
provenance-weighted cascade. Every conflict lands in one of a few arms:
|
|
58
|
+
|
|
59
|
+
- **WINNER** — a live (non-stale) write wins on authority → confidence. No
|
|
60
|
+
re-run, no human. This is the win over OCC.
|
|
61
|
+
- **FORK** — two+ fresh writes tie; defer to a human/high-tier agent instead of
|
|
62
|
+
silently dropping one.
|
|
63
|
+
- **RECOMPUTE** — every competing write is premise-stale; there's no correct
|
|
64
|
+
value to pick, so re-run. Here you're no better than OCC.
|
|
65
|
+
|
|
66
|
+
## Layout
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
cascade/ importable package
|
|
70
|
+
cascade_routing.py core resolution router (OCC vs cascade vs hybrid)
|
|
71
|
+
server.py MCP stdio server wrapping the router as tools
|
|
72
|
+
cascade_sim.py standalone go/no-go regime simulator
|
|
73
|
+
scripts/ data-generation / audit utilities
|
|
74
|
+
gen_agent_logs.py emit agent_logs.csv across the regime × policy grid
|
|
75
|
+
audit_cherrypick.py adversarial read of agent_logs.csv
|
|
76
|
+
validate_logs.py quick sanity checks on a generated CSV
|
|
77
|
+
tests/ verification suite
|
|
78
|
+
test_agent_logs.py 43-check self-consistency + usability suite over the CSV
|
|
79
|
+
test_mcp_wrapper.py routes the regime grid through the MCP wrapper and
|
|
80
|
+
re-runs the suite to prove the wrapper preserves behavior
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Large simulation outputs (`agent_logs.csv`, `agent_logs_mcp.csv`, ~900 MB each)
|
|
84
|
+
are regenerable and are gitignored.
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
- Python ≥ 3.10 (developed on 3.13)
|
|
89
|
+
- [`mcp`](https://pypi.org/project/mcp/) — installed automatically as a dependency
|
|
90
|
+
|
|
91
|
+
## Install & attach to an MCP client
|
|
92
|
+
|
|
93
|
+
Once published to PyPI, no clone or virtualenv is needed — [`uvx`](https://docs.astral.sh/uv/)
|
|
94
|
+
runs the server in an ephemeral environment:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
uvx cascade-mcp
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
To attach the router to **Claude Desktop** or **Cursor**, add this to your
|
|
101
|
+
`claude_desktop_config.json`:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"mcpServers": {
|
|
106
|
+
"cascade": {
|
|
107
|
+
"command": "uvx",
|
|
108
|
+
"args": ["cascade-mcp"]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The MCP server exposes five tools: `configure`, `read_state`, `propose_update`,
|
|
115
|
+
`churn`, `get_field`.
|
|
116
|
+
|
|
117
|
+
## Usage (from source)
|
|
118
|
+
|
|
119
|
+
Clone the repo and run everything **from the repo root**.
|
|
120
|
+
|
|
121
|
+
Run the MCP server (stdio):
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
python -m cascade.server
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Run the standalone simulator:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
python -m cascade.cascade_sim
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Generate the stress-test CSV (writes UTF-8 — pipe via a POSIX shell, **not**
|
|
134
|
+
PowerShell `>`, which re-encodes to UTF-16 and corrupts the file):
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
python scripts/gen_agent_logs.py > agent_logs.csv
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Verify the generated CSV:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
python -m tests.test_agent_logs # 43-check suite
|
|
144
|
+
python scripts/audit_cherrypick.py # adversarial cross-checks
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Verify the MCP wrapper preserves the router's behavior end-to-end (wire-protocol
|
|
148
|
+
smoke test → regime grid through the wrapper → re-run the suite):
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
python -m tests.test_mcp_wrapper
|
|
152
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
cascade/__init__.py,sha256=g3tK6_JLleAXkdJ8CZAOzJeY6MqaeguI7fL3nO6oBHU,284
|
|
2
|
+
cascade/cascade_routing.py,sha256=BgP6NV4YA19-u1ky5Q55Y1vLKu1srNO54_Eewj3d2MM,8357
|
|
3
|
+
cascade/cascade_sim.py,sha256=C3ReNNQJ7RBaqnHN0jLjrWkd3ARznvPLJHfZZom4kcE,13647
|
|
4
|
+
cascade/server.py,sha256=o5XXmxrBnDiDrMnJ8Qby_xE4C3mUyWcc1VPqoy9DDgY,16056
|
|
5
|
+
cascade_mcp-0.1.0.dist-info/METADATA,sha256=AxSnfVFlUy4DDqeHJjtbpPPt6-371yuVNkQilK0g91I,5909
|
|
6
|
+
cascade_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
cascade_mcp-0.1.0.dist-info/entry_points.txt,sha256=JjoVpF8tLyfqtSkcvCLCAES5RLeXd3l00fkkv3KQlLI,51
|
|
8
|
+
cascade_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=BT6a36j8u_AQmHmhEU7Md6yreS5_E6MqZf5digarpFY,1083
|
|
9
|
+
cascade_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pedro Clemente-Turrubiates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|