Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ state/ volatile runtime signals; gitignored
.watch.lock .wake-queue.lock watcher singleton and queue serialization locks
.claude-autoarm.lock .claude-autoarm-epoch .claude-autoarm-failure-notified .claude-autoarm-failure-alarmed .turnend-claude-blocks .turnend-claude-blocks.lock Claude Stop auto-arm single-flight, epoch, failure-episode, attended-alarm, guard-budget, and budget-lock records; never touch
.hash-* .count-* .stale-* .stale-since-* .paused-* .wedge-escalations-* .seen-* .hb-surfaced-* .last-* .heartbeat-streak watcher internals; never touch
.watch-triage.log watcher's absorbed-wake debug log (size-capped); never relied on, safe to delete
.watch-triage.log watcher's observational debug log (size-capped); never relied on, safe to delete

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Settlement state lacks an owner

The state inventory omits .wedge-settlements.jsonl and its trim file. Document whether this cross-session sample is disposable or belongs with durable fleet data.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

.last-watcher-beat watcher liveness beacon, touched every poll (including while absorbing benign wakes); guard scripts read it
.subsuper-* .supervise-daemon.* sub-supervisor internals; never touch
.no-mistakes/ local validation state and evidence; gitignored
Expand Down
241 changes: 241 additions & 0 deletions bin/argyle_gates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# Provenance: lifted verbatim from cipherholdingsllc/nakatomi
# branch t2-argyle-frontier, path crypto-crawler/crypto_crawler/argyle/gates.py
# (commit 02be13ca139e847089d3ce7c6503d9f5dae914ba).
# Pure stdlib. Do not edit here; upstream fixes land in nakatomi first.
"""
Promotion gates — the bar every learned knob must clear before it may touch
the mock book. Three tests, ALL required (logical AND):

1. Deflated Sharpe (Bailey & López de Prado 2014): the observed Sharpe must
be significant AFTER deflating for the number of configurations actually
tried. Sweeping N configs and reporting the best is the quant original
sin; the deflation term grows with N, so a small sample cannot launder a
lucky config into a promotion.
2. Block-permutation test: the strategy's edge statistic must beat p < 0.05
against block-shuffled nulls (blocks preserve short-range autocorrelation
that a naive i.i.d. shuffle would destroy — shuffling away autocorrelation
makes nulls too easy to beat).
3. Out-of-sample calibration: mean Brier of the candidate's p_up on held-out
outcomes must beat CLIMATOLOGY — the constant base-rate predictor, whose
Brier is p̄(1−p̄) ≤ 0.25. A fixed 0.25 bar is launderable at skewed base
rates (constant p=0.9 on 90%-up outcomes scores 0.09 with zero skill —
found by adversarial audit 2026-07-04); climatology is not.

Everything is pure Python + math (no scipy — this box is minimal, and a gate
must not fail-open because a dependency is missing). Normal CDF via math.erf;
inverse normal via Acklam's rational approximation (|ε| < 1.15e-9, far below
any decision boundary here).

Honest-refusal posture: with too little data the gate returns passed=False
with reason "insufficient data" — it never extrapolates a pass. On ~teens of
settled findings the EXPECTED outcome is failure; that is the gate working.
"""

from __future__ import annotations

import math
import random
from dataclasses import dataclass, field

EULER_GAMMA = 0.5772156649015329


def norm_cdf(x: float) -> float:
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))


def norm_ppf(p: float) -> float:
"""Acklam's inverse-normal approximation. Pure, dependency-free."""
if not 0.0 < p < 1.0:
raise ValueError(f"p must be in (0,1), got {p}")
a = (-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00)
b = (-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
6.680131188771972e+01, -1.328068155288572e+01)
c = (-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00)
d = (7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
3.754408661907416e+00)
p_low, p_high = 0.02425, 1 - 0.02425
if p < p_low:
q = math.sqrt(-2 * math.log(p))
return (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / \
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
if p > p_high:
q = math.sqrt(-2 * math.log(1 - p))
return -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / \
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1)
q = p - 0.5
r = q * q
return (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / \
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1)


def _moments(xs: list[float]) -> tuple[float, float, float, float]:
"""(mean, std, skew, kurtosis) — kurtosis is NON-excess (normal = 3.0),
matching the PSR formula's convention. Sample std (ddof=1)."""
n = len(xs)
mean = sum(xs) / n
var = sum((x - mean) ** 2 for x in xs) / (n - 1)
std = math.sqrt(var)
if std == 0.0:
return mean, 0.0, 0.0, 3.0
skew = sum(((x - mean) / std) ** 3 for x in xs) / n
kurt = sum(((x - mean) / std) ** 4 for x in xs) / n
return mean, std, skew, kurt


def sharpe(returns: list[float]) -> float:
"""Per-period, non-annualized. Sample std."""
mean, std, _, _ = _moments(returns)
if std == 0.0:
return 0.0
return mean / std


@dataclass
class GateResult:
passed: bool
reason: str
detail: dict = field(default_factory=dict)


def deflated_sharpe(
returns: list[float],
n_trials: int,
var_sr_trials: float | None = None,
confidence: float = 0.95,
) -> GateResult:
"""DSR = PSR evaluated at the expected-max-Sharpe benchmark under n_trials.

SR0 = sqrt(V[SR_n]) * ((1-γ)·Φ⁻¹(1-1/N) + γ·Φ⁻¹(1-1/(N·e)))
DSR = Φ( (SR - SR0)·sqrt(T-1) / sqrt(1 - γ₃·SR + (γ₄-1)/4·SR²) )

`var_sr_trials` is the cross-trial variance of the Sharpe estimates from
the actual config sweep — pass it when the sweep recorded per-config SRs.
When unavailable, the estimator's own asymptotic variance is used as a
stand-in (documented compromise: it under-deflates a heterogeneous sweep,
so prefer real trial variance for any promotion decision)."""
t = len(returns)
if t < 10:
return GateResult(False, f"insufficient data: {t} returns < 10")
if n_trials < 1:
return GateResult(False, f"n_trials must be >= 1, got {n_trials}")
sr = sharpe(returns)
_, _, skew, kurt = _moments(returns)
denom = 1.0 - skew * sr + (kurt - 1.0) / 4.0 * sr * sr
if denom <= 0.0:
return GateResult(False, "PSR variance term non-positive (pathological moments)",
{"sr": sr, "skew": skew, "kurt": kurt})
if var_sr_trials is None:
var_sr_trials = denom / (t - 1)
if n_trials == 1:
sr0 = 0.0
else:
e = math.e
sr0 = math.sqrt(max(var_sr_trials, 0.0)) * (
(1 - EULER_GAMMA) * norm_ppf(1 - 1.0 / n_trials)
+ EULER_GAMMA * norm_ppf(1 - 1.0 / (n_trials * e))
)
dsr = norm_cdf((sr - sr0) * math.sqrt(t - 1) / math.sqrt(denom))
passed = dsr >= confidence
return GateResult(
passed,
f"DSR={dsr:.4f} {'≥' if passed else '<'} {confidence} (SR={sr:.4f}, SR0={sr0:.4f}, "
f"N={n_trials}, T={t})",
{"dsr": dsr, "sr": sr, "sr0": sr0, "n_trials": n_trials, "t": t},
)


def block_permutation(
directions: list[float],
returns: list[float],
n_perm: int = 2000,
block: int | None = None,
seed: int = 0,
alpha: float = 0.05,
) -> GateResult:
"""H0: directions carry no information about returns. Statistic is the
mean directional capture mean(d_i·r_i). Null draws are a CIRCULAR BLOCK
BOOTSTRAP of the return series (blocks drawn with replacement, order
within blocks preserved) — short-range autocorrelation survives, and the
p-value is approximate rather than an exact permutation p. Seeded — same
inputs, same p. Add-one estimator (Phipson & Smyth) so p is never 0."""
n = len(returns)
if n != len(directions):
return GateResult(False, "directions/returns length mismatch")
if n < 8:
return GateResult(False, f"insufficient data: {n} pairs < 8")
if block is None:
block = max(1, round(n ** (1.0 / 3.0))) # standard b ~ n^(1/3) rule
obs = sum(d * r for d, r in zip(directions, returns)) / n
rng = random.Random(seed)
n_blocks = math.ceil(n / block)
hits = 0
for _ in range(n_perm):
starts = [rng.randrange(n) for _ in range(n_blocks)]
permuted: list[float] = []
for s in starts:
for j in range(block):
if len(permuted) == n:
break
permuted.append(returns[(s + j) % n])
stat = sum(d * r for d, r in zip(directions, permuted)) / n
if stat >= obs:
hits += 1
p = (hits + 1) / (n_perm + 1)
passed = p < alpha
return GateResult(
passed,
f"block-permutation p={p:.4f} {'<' if passed else '≥'} {alpha} "
f"(obs={obs:.6f}, block={block}, n_perm={n_perm})",
{"p": p, "obs": obs, "block": block, "n_perm": n_perm},
)


def oos_calibration(p_up: list[float], outcomes: list[int]) -> GateResult:
"""Mean Brier of held-out p_up must beat the climatology Brier p̄(1−p̄) —
the score of constantly predicting the realized base rate. This is the
skill bar a constant predictor cannot pass by construction."""
if len(p_up) != len(outcomes):
return GateResult(False, "p_up/outcomes length mismatch")
if len(p_up) < 10:
return GateResult(False, f"insufficient data: {len(p_up)} pairs < 10")
brier = sum((p - o) ** 2 for p, o in zip(p_up, outcomes)) / len(p_up)
base_rate = sum(outcomes) / len(outcomes)
climatology = base_rate * (1.0 - base_rate)
passed = brier < climatology
return GateResult(
passed,
f"OOS Brier={brier:.4f} {'<' if passed else '≥'} climatology "
f"{climatology:.4f} (p̄={base_rate:.3f}, n={len(p_up)}, coin-flip bound 0.25)",
{"brier": brier, "n": len(p_up), "climatology": climatology,
"base_rate": base_rate, "coin_flip": 0.25},
)


def promotion_gate(
returns: list[float],
n_trials: int,
directions: list[float],
p_up: list[float],
outcomes: list[int],
var_sr_trials: float | None = None,
seed: int = 0,
) -> GateResult:
"""The full gate: deflated Sharpe AND block permutation AND OOS calibration.
Returns one GateResult whose detail carries all three components — this
dict IS the promotion receipt body."""
dsr = deflated_sharpe(returns, n_trials, var_sr_trials)
perm = block_permutation(directions, returns, seed=seed)
cal = oos_calibration(p_up, outcomes)
passed = dsr.passed and perm.passed and cal.passed
reason = " | ".join(
f"{name}: {'PASS' if g.passed else 'FAIL'} ({g.reason})"
for name, g in (("deflated_sharpe", dsr), ("block_permutation", perm), ("oos_calibration", cal))
)
return GateResult(passed, reason, {
"deflated_sharpe": {"passed": dsr.passed, **dsr.detail},
"block_permutation": {"passed": perm.passed, **perm.detail},
"oos_calibration": {"passed": cal.passed, **cal.detail},
})
1 change: 1 addition & 0 deletions bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ family_for_basename() {
fm-kimi-harness.test.sh|fm-muse-harness.test.sh|fm-herdr-lab.test.sh|fm-lint.test.sh|\
fm-operational-input.test.sh|fm-pi-primary-types.test.sh|\
fm-send-popup-settle.test.sh|fm-send-settle.test.sh|\
fm-wedge-score.test.sh|\
fm-subagent-pretool-check.test.sh|\
fm-supervision-instructions.test.sh|fm-task-delivery.test.sh|\
fm-tmux-submit-busy.test.sh|fm-trace-context-lib.test.sh|\
Expand Down
4 changes: 2 additions & 2 deletions bin/fm-watch-arm.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
# state/.watch-cycle-exits.log. The arm layer owns that bounded ledger; it records
# arm/watcher identities, timestamps, exit/signal classification, beacon age,
# lock identity before and after close, and successor disposition. The separate
# state/.watch-triage.log remains exclusively the watcher's absorbed-wake debug
# log and is never written here.
# state/.watch-triage.log is the watcher's own bounded observational debug log,
# carries no lifecycle semantics, and is never written here.
#
# --restart: stop ONLY this FM_HOME's watcher (the pid recorded in THIS home's
# state/.watch.lock) and own a fresh cycle, or attach if a verified live peer
Expand Down
40 changes: 40 additions & 0 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,40 @@ recorded_windows() {
# below).
FM_WEDGE_DEMAND_INSPECT_COUNT=${FM_WEDGE_DEMAND_INSPECT_COUNT:-3}

# Shadow mode records wedge settlements and logs a graded score beside the fixed
# timer, but never changes escalation.
wedge_shadow_settle() { # <window> <task> <idle-secs> <outcome>
local win=$1 task=$2 idle=$3 outcome=$4
[ "${FM_WEDGE_SHADOW:-1}" = 1 ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
"$SCRIPT_DIR/fm-wedge-score.py" settle --state "$STATE" \
--window "$win" --task "$task" --idle-secs "$idle" --outcome "$outcome" \
>/dev/null 2>&1 || true
}

wedge_shadow_score() { # <task> <idle-secs>
local task=$1 idle=$2 score
[ "${FM_WEDGE_SHADOW:-1}" = 1 ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
score=$("$SCRIPT_DIR/fm-wedge-score.py" score --state "$STATE" \
--task "$task" --idle-secs "$idle" \
--fixed-threshold "$STALE_ESCALATE_SECS" 2>/dev/null) || true
[ -n "$score" ] && triage_log "shadow wedge score: $score" || true
}

wedge_shadow_resumed() { # <window> <task> <since-file>
local win=$1 task=$2 since_file=$3 since idle
[ "${FM_WEDGE_SHADOW:-1}" = 1 ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
[ -e "$since_file" ] || return 0
since=$(cat "$since_file" 2>/dev/null || true)
case "$since" in
''|*[!0-9]*) return 0 ;;
esac
idle=$(( $(date +%s) - since ))
wedge_shadow_settle "$win" "$task" "$idle" resumed || true
}

# Repeat-poll wedge-timer bookkeeping for an already-classified stale hash
# absorbed as provably-working - repairs a missing/corrupt timer (self-heals a
# watcher restart between recording the hash and recording the timer), or
Expand All @@ -292,6 +326,8 @@ wedge_timer_check() { # <window> <since-file> <triage-label> <escalation-count-
if [ "$n" -ge "$FM_WEDGE_DEMAND_INSPECT_COUNT" ]; then
reason="stale: $win (idle ${age}s, possible wedge, escalation $n, demand-deep-inspection: same pane has wedge-escalated $n times in a row - do not re-absorb on the run-step/pane state alone)"
fi
wedge_shadow_settle "$win" "$(window_to_task "$win" "$STATE")" "$age" escalated || true
wedge_shadow_score "$(window_to_task "$win" "$STATE")" "$age" || true
fm_wake_append stale "$win" "$reason" || exit 1
rm -f "$since_file"
wake "$reason"
Expand Down Expand Up @@ -359,6 +395,8 @@ clear_pause_tracking() { # <window>
key=${key//\//_}
key=${key//./_}
clear_pause_state "$win"
wedge_shadow_resumed "$win" "$(window_to_task "$win" "$STATE")" \
"$STATE/.stale-since-$key" || true
Comment on lines +398 to +399

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Pause cleanup records false resumptions

When an over-age busy pane leaves pause tracking, clear_pause_tracking records a zero-second resumption despite continued inactivity. wedge_timer_check creates the timer immediately before this cleanup. The false healthy sample distorts every later lane score.

Learn more

Pause tracking can coexist with a busy pane whose completed-turn age exceeds BUSY_TURN_MAX_SECS. In that case, the busy-pane branch calls wedge_timer_check, which creates a missing timer, then immediately calls clear_pause_tracking. The new call records that freshly created timer as a resume even though no turn completed and the pane remains busy. The recorded idle time is usually zero seconds. The scorer clamps it to one second before taking its logarithm, making it an extreme low healthy observation for the lane.

Example: A paused pane becomes busy but completes no turn for over an hour. Its first qualifying poll creates .stale-since-*; pause cleanup immediately appends {"idle_secs":0,"outcome":"resumed"} although the pane is still inactive.

Recommended fix: Remove settlement recording from clear_pause_tracking. Record resumed settlements only in branches that observe a genuine pane/hash recovery while an existing wedge timer remains active.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

rm -f "$STATE/.stale-$key" "$STATE/.stale-since-$key" "$STATE/.wedge-escalations-$key"
}

Expand Down Expand Up @@ -1061,6 +1099,7 @@ EOF
if [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then
wedge_timer_check "$w" "$ssf" "busy (no completed turn)" "$ewf"
else
wedge_shadow_resumed "$w" "$task" "$ssf" || true
rm -f "$ssf" "$ewf"
fi
if [ -e "$pf" ] && { [ "$n" -ge 2 ] || ! status_is_paused_or_captain_held "$(last_status_line "$STATE/$(window_to_task "$w" "$STATE").status")"; }; then
Expand All @@ -1073,6 +1112,7 @@ EOF
if [ "$busy_now" -eq 0 ] && busy_turn_over_age "$task"; then
wedge_timer_check "$w" "$ssf" "busy (no completed turn)" "$ewf"
else
wedge_shadow_resumed "$w" "$task" "$ssf" || true
rm -f "$ssf" "$ewf"
fi
task=$(window_to_task "$w" "$STATE")
Expand Down
Loading
Loading