15 Commits

Author SHA1 Message Date
Codex
519565965e docs: Cubic→Linear translator fixes + Beads PASS tracker evaluation
- NEW_PINK_FORENSICS_DUAL_LEV_2026_SEARCH_RESULTS_SPEC.md:94 — 'cubic translator' → 'linear translator' (exchange mapping)
- VIOLET_STUDY_SPEC__BASE_FRACTION_SIZING.md:19 — 'cubic translator' → 'linear translator'
- VIOLET_V3_FINDINGS.md:51 — 'cubic translator' → 'linear translator'
- BEADS_PASS_TRACKER_EVALUATION.md: New evaluation recommending ADOPT for PASS tracking

Per Fable: exchange leverage mapping is LINEAR (round_half_even); cubic is conviction sizer only.
2026-07-08 17:48:38 +02:00
Codex
5607bfcc2c skills: pi_wake_agent skill definition (YAML + JSON)
- pi_wake_agent.skill.yaml: Full skill definition with all capabilities, parameters, examples, error handling, testing
- pi_wake_agent.skill.json: OpenAI function calling compatible schema
2026-07-08 16:15:59 +02:00
Codex
722ead5384 docs: Add --dry-run to PI_WAKE_AGENT_TOOL.md 2026-07-08 15:26:15 +02:00
Codex
88eaf20363 pi_wake_agent.py: Add --dry-run flag
- --dry-run shows what would be done without executing
- Logs intended actions to log file
- Exits 0 without modifying crontab or sending wake messages
2026-07-08 15:18:36 +02:00
Codex
d9284b7b75 docs/tools: pi_wake_agent tool documentation + test suite
- prod/docs/PI_WAKE_AGENT_TOOL.md: complete usage documentation
- test_pi_wake_agent.py: 38 test cases
2026-07-08 14:21:18 +02:00
Codex
de561b88b1 tools: pi_wake_agent.py — Reusable multi-agent wake timer with self-cron/daemon/succession
- Modes: --install, --once, --daemon, --succession, --run, --remove, --list, --status, --validate
- Multi-session support (--session / --sessions)
- Non-blocking h5i bus messages (fire-and-forget)
- Self-cleaning succession mode (--succession --count N --interval X)
- 38 tests passing
2026-07-08 14:14:50 +02:00
Codex
2faa179957 pi_wake_agent.py: Add run_once and run_wake with non-blocking h5i bus 2026-07-08 13:28:05 +02:00
Codex
0a586da6af pi_wake_agent.py: Add succession mode, fix h5i non-blocking 2026-07-08 11:05:52 +02:00
Codex
9546ad6c7b docs: record shared memory formats and addressing 2026-07-04 19:58:47 +02:00
Codex
a098962eec docs(uv): testnet push — T10 Gate A/B run spec, T12 promotion bridge spec, operator arming checklist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:14:38 +02:00
Codex
c725bae815 docs(uv): handover board-state update — T2P2 differ merged, T2 rerouted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:46:14 +02:00
Codex
3b2b6987ce docs(uv): handover to successor integrator + SOA verdict + T8/T9 subspecs + spec C11 forensics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:47:37 +02:00
Codex
81520af83c dita_v2: fix arithmetic in test_asex_account mixed-ops funding expectation (2x50x0.5=50, not 100)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 08:03:36 +02:00
Codex
da2d140b3d record live state: ch_writer poison-row/dead_letter quarantine hotfix + violet_decisions DDL entry
NO content authored here — this commits the long-uncommitted LIVE hotfix from
incident 2026-06-12 (bars_held UInt16 poison jammed 18M rows 1.5d): poison rows
retried individually after CH_POISON_ATTEMPTS, then quarantined to dead_letter;
includes the ids loop-variable bugfix. Uncommitted live code was one checkout
away from loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:17:03 +02:00
Codex
a7522bf8d1 dita_v2: reconcile SOA — vendored thread-safety hardening ∪ venue plane
3-way merge (base=pre-venue-plane upstream, ours=violet-main vendored hardening,
theirs=upstream venue plane): RLock atomic-snapshot wrapping (account.py,
real_control_plane, real_zinc_plane), lazy __getattr__ imports (__init__),
account-core test coverage — merged with venue_region telemetry. Zero conflicts;
all files AST-verified. Ends the two-way vendor drift found in
DITAV2_SOA_SURVEY_20260702 §5 / UV_DITAV2_SOA_VERDICT_20260703.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:15:17 +02:00
27 changed files with 3750 additions and 173 deletions

461
pi_wake_agent.py Normal file
View File

@@ -0,0 +1,461 @@
#!/usr/bin/env python3
"""
pi_wake_agent.py — Reusable multi-agent wake-up timer with self-cron/daemon/succession
Usage:
pi_wake_agent.py --install --interval 1h --session cc_UV_dev0_Fb --msg "Operator says CONTINUE. Pi here!"
pi_wake_agent.py --once --interval 2h --session cc_UV_dev0_Fb --msg "Time's up!"
pi_wake_agent.py --daemon --interval 1h --session cc_UV_dev0_Fb
pi_wake_agent.py --succession --count 3 --interval 1h --session cc_UV_dev0_Fb --msg "Scheduled wake"
pi_wake_agent.py --remove --session cc_UV_dev0_Fb --interval 1h
pi_wake_agent.py --list
pi_wake_agent.py --status
pi_wake_agent.py --validate --session cc_UV_dev0_Fb
"""
import argparse
import logging
import os
import re
import shlex
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import List, Optional
# ─── Constants ────────────────────────────────────────────────────────────
SCRIPT_PATH = Path(__file__).resolve()
LOG_FILE = Path("/tmp/pi_wake_agent.log")
LOG_MAX_SIZE = 10 * 1024 * 1024 # 10 MB
LOG_MAX_FILES = 5
CRON_COMMENT_PREFIX = "pi_wake_agent"
AGENT_NICK = "pi_nvnemo"
H5I_AGENT = "pi_nvnemo"
H5I_BUS_ROOT = Path("/mnt/dolphinng5_predict")
DEFAULT_INTERVAL = "1h"
# ─── Logging Setup ───────────────────────────────────────────────────────
def setup_logging(debug: bool = False) -> logging.Logger:
log_rotate()
logger = logging.getLogger("pi_wake_agent")
logger.setLevel(logging.DEBUG if debug else logging.INFO)
fh = logging.FileHandler(LOG_FILE)
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
logger.addHandler(fh)
ch = logging.StreamHandler(sys.stderr)
ch.setLevel(logging.WARNING)
ch.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
logger.addHandler(ch)
return logger
def log_rotate() -> None:
if not LOG_FILE.exists():
return
size = LOG_FILE.stat().st_size
if size < LOG_MAX_SIZE:
return
for i in range(LOG_MAX_FILES - 1, 0, -1):
src = LOG_FILE.with_suffix(f".log.{i}") if i > 1 else LOG_FILE.with_suffix(".log.1")
if src.exists():
dst = LOG_FILE.with_suffix(f".log.{i + 1}")
src.rename(dst)
LOG_FILE.rename(LOG_FILE.with_suffix(".log.1"))
# ─── Interval Parsing ────────────────────────────────────────────────────
def parse_interval(interval: str) -> int:
"""Parse interval string (e.g., '1h', '30m', '90m', '2h', '10s') to seconds."""
match = re.match(r"^(\d+)([hms])$", interval)
if not match:
raise ValueError(f"Invalid interval format '{interval}'. Use like 1h, 30m, 90m, 2h, 10s")
value, unit = int(match.group(1)), match.group(2)
if unit == "h":
return value * 3600
elif unit == "m":
return value * 60
elif unit == "s":
return value
raise ValueError(f"Unknown unit: {unit}")
def interval_to_cron(interval: str) -> str:
"""Convert interval to cron schedule."""
match = re.match(r"^(\d+)([hm])$", interval)
if not match:
raise ValueError(f"Cron only supports minutes/hours intervals: {interval}")
value, unit = int(match.group(1)), match.group(2)
if unit == "h":
return f"0 */{value} * * *"
elif unit == "m":
if value >= 60:
raise ValueError(f"For minutes >= 60, use hours (e.g., 1h not 60m)")
return f"*/{value} * * * *"
raise ValueError(f"Cron only supports minutes/hours intervals: {interval}")
def interval_to_human(interval: str) -> str:
match = re.match(r"^(\d+)([hms])$", interval)
if not match:
return interval
value, unit = match.group(1), match.group(2)
if unit == "h":
return f"{value} hour(s)"
elif unit == "m":
return f"{value} minute(s)"
elif unit == "s":
return f"{value} second(s)"
return interval
# ─── Cron Management ────────────────────────────────────────────────────
def cron_comment(sessions: List[str], interval: str) -> str:
sessions_str = ",".join(sessions)
return f"{CRON_COMMENT_PREFIX}:{sessions_str}:{interval}"
def install_cron(sessions: List[str], interval: str, message: str, logger: logging.Logger) -> None:
cron_sched = interval_to_cron(interval)
comment = cron_comment(sessions, interval)
sessions_arg = f"'{','.join(sessions)}'"
cmd = f"cd {H5I_BUS_ROOT} && export H5I_AGENT={H5I_AGENT} && {SCRIPT_PATH} --run --sessions {sessions_arg} --msg {shlex.quote(message)}"
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
existing = result.stdout if result.returncode == 0 else ""
lines = [line for line in existing.splitlines() if comment not in line]
lines.append(f"{cron_sched} {cmd} # {comment}")
new_cron = "\n".join(lines) + "\n"
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
logger.info(f"Installed cron: {cron_sched} -> {sessions} every {interval}")
def remove_cron(sessions: List[str], interval: str, logger: logging.Logger) -> None:
comment = cron_comment(sessions, interval)
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
existing = result.stdout if result.returncode == 0 else ""
lines = [line for line in existing.splitlines() if comment not in line]
new_cron = "\n".join(lines) + ("\n" if lines else "")
subprocess.run(["crontab", "-"], input=new_cron, text=True, check=True)
logger.info(f"Removed cron for {sessions} ({interval})")
def list_cron(logger: logging.Logger) -> None:
print("=== pi_wake_agent cron entries ===")
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
existing = result.stdout if result.returncode == 0 else ""
found = False
for line in existing.splitlines():
if CRON_COMMENT_PREFIX in line:
print(f" {line}")
found = True
if not found:
print(" (none)")
# ─── Zellij Operations ──────────────────────────────────────────────────
def zellij_session_exists(session: str) -> bool:
try:
result = subprocess.run(["zellij", "list-sessions"], capture_output=True, text=True, timeout=5)
clean = re.sub(r'\x1b\[[0-9;]*m', '', result.stdout)
for line in clean.splitlines():
if line.startswith(session + " ") or line == session:
return True
except Exception:
pass
return False
def zellij_write_chars(session: str, text: str) -> bool:
try:
subprocess.run(["zellij", "--session", session, "action", "write-chars", text],
capture_output=True, timeout=5)
return True
except Exception:
return False
def zellij_write_enter(session: str) -> bool:
try:
subprocess.run(["zellij", "--session", session, "action", "write", "13"],
capture_output=True, timeout=5)
return True
except Exception:
return False
# ─── Wake Action ────────────────────────────────────────────────────────────────────
logger.info(f"One-shot timer set for {interval} ({interval_to_human(interval)})")
def _wake():
time.sleep(interval_seconds)
run_wake(sessions, message, logger)
thread = threading.Thread(target=_wake, daemon=True)
thread.start()
logger.info(f"Background timer started (thread: {thread.ident})")
pid_file = Path(f"/tmp/pi_wake_agent_{sessions[0]}.pid")
pid_file.write_text(str(os.getpid()))
def run_wake(sessions: List[str], message: str, logger: logging.Logger) -> None:
sessions_str = ",".join(sessions)
logger.info(f"Waking {sessions_str} with message: {message}")
for session in sessions:
zellij_write_chars(session, f"[{AGENT_NICK} via zellij] {message} Run: h5i-bus msg inbox")
for _ in range(5):
zellij_write_enter(session)
time.sleep(1)
# Fire-and-forget h5i bus message (non-blocking)
def _send_bus():
try:
subprocess.run(
["h5i", "msg", "send", "Fable", f"{message} (timer wakeup)"],
cwd=H5I_BUS_ROOT,
env={**os.environ, "H5I_AGENT": H5I_AGENT},
capture_output=True,
timeout=5
)
except Exception:
pass # Silently ignore - fire and forget
threading.Thread(target=_send_bus, daemon=True).start()
logger.info(f"Wake sent to {sessions_str}")
def run_once(sessions: List[str], interval: str, message: str, logger: logging.Logger) -> None:
interval_seconds = parse_interval(interval)
logger.info(f"One-shot timer set for {interval} ({interval_to_human(interval)})")
def _wake():
time.sleep(interval_seconds)
run_wake(sessions, message, logger)
thread = threading.Thread(target=_wake, daemon=True)
thread.start()
logger.info(f"Background timer started (thread: {thread.ident})")
pid_file = Path(f"/tmp/pi_wake_agent_{sessions[0]}.pid")
pid_file.write_text(str(os.getpid()))
return 0
def run_daemon(sessions: List[str], interval: str, message: str, logger: logging.Logger) -> None:
interval_seconds = parse_interval(interval)
logger.info("=== DAEMON START ===")
logger.info(f"Interval: {interval} ({interval_to_human(interval)})")
logger.info(f"Sessions: {sessions}")
logger.info(f"Message: {message}")
run_wake(sessions, message, logger)
while True:
logger.debug(f"Sleeping for {interval_seconds}s...")
time.sleep(interval_seconds)
run_wake(sessions, message, logger)
def run_succession(sessions: List[str], interval: str, count: int, message: str, logger: logging.Logger) -> None:
"""Run wake N times at interval, then self-clean (remove cron if installed)."""
interval_seconds = parse_interval(interval)
logger.info(f"=== SUCCESSION START === Count: {count}, Interval: {interval} ({interval_to_human(interval)})")
logger.info(f"Sessions: {sessions}")
logger.info(f"Message: {message}")
for i in range(1, count + 1):
logger.info(f"Succession {i}/{count}")
run_wake(sessions, message, logger)
if i < count:
logger.debug(f"Sleeping for {interval_seconds}s until next succession...")
time.sleep(interval_seconds)
# Self-clean: remove any cron entry for this session/interval combo
try:
remove_cron(sessions, interval, logger)
logger.info("Self-cleanup complete (cron removed)")
except Exception as e:
logger.warning(f"Self-cleanup failed: {e}")
logger.info("=== SUCCESSION COMPLETE ===")
def status(logger: logging.Logger) -> None:
print("=== pi_wake_agent Status ===")
print(f"Script: {SCRIPT_PATH}")
print(f"Log: {LOG_FILE}")
print(f"Log size: {LOG_FILE.stat().st_size if LOG_FILE.exists() else 'N/A'} bytes")
print(f"Agent: {AGENT_NICK}")
print()
list_cron(logger)
print()
print("=== Active one-shot timers ===")
found = False
for pid_file in Path("/tmp").glob("pi_wake_agent_*.pid"):
found = True
try:
pid = int(pid_file.read_text().strip())
os.kill(pid, 0)
print(f" PID {pid} (active)")
except (ProcessLookupError, ValueError):
print(f" PID {pid_file.read_text().strip()} (dead, cleaning up)")
pid_file.unlink(missing_ok=True)
if not found:
print(" (none)")
def validate_sessions(sessions: List[str], logger: logging.Logger) -> None:
logger.info(f"Validating sessions: {sessions}")
for session in sessions:
if zellij_session_exists(session):
logger.info(f" {session}: EXISTS")
else:
logger.warning(f" {session}: NOT FOUND (may be dead/EXITED)")
# ─── Session Parsing ─────────────────────────────────────────────────────
def parse_sessions(raw) -> List[str]:
if not raw:
return []
if isinstance(raw, list):
return [s.strip() for s in raw]
return [s.strip() for s in raw.split(",") if s.strip()]
# ─── Usage ──────────────────────────────────────────────────────────────
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="pi_wake_agent.py — Reusable multi-agent wake-up timer with self-cron/daemon/succession",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
EXAMPLES:
# Install recurring 1-hour timer for one session
pi_wake_agent.py --install --interval 1h --session cc_UV_dev0_Fb
# Install 30-minute timer for multiple sessions
pi_wake_agent.py --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"
# One-shot wake in 2 hours (no cron)
pi_wake_agent.py --once --interval 2h --session cc_UV_dev0_Fb --msg "Time's up!"
# Run N times at interval, then self-clean
pi_wake_agent.py --succession --count 3 --interval 1h --session cc_UV_dev0_Fb --msg "Scheduled wake"
# Run as daemon (long-lived process, no cron)
pi_wake_agent.py --daemon --interval 1h --session cc_UV_dev0_Fb
# Remove timer
pi_wake_agent.py --remove --session cc_UV_dev0_Fb --interval 1h
# List all timers
pi_wake_agent.py --list
# Show status
pi_wake_agent.py --status
# Validate sessions
pi_wake_agent.py --validate --session cc_UV_dev0_Fb
"""
)
parser = argparse.ArgumentParser(
description="pi_wake_agent.py — Reusable multi-agent wake-up timer with self-cron/daemon/succession",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--install", action="store_const", const="install", dest="mode", help="Install recurring cron timer")
parser.add_argument("--once", action="store_const", const="once", dest="mode", help="One-shot wake (no cron)")
parser.add_argument("--daemon", action="store_const", const="daemon", dest="mode", help="Run as long-lived daemon (no cron)")
parser.add_argument("--succession", action="store_const", const="succession", dest="mode", help="Run N times at interval, then self-clean")
parser.add_argument("--count", type=int, default=1, help="Number of successions (for --succession mode)")
parser.add_argument("--run", action="store_const", const="run", dest="mode", help="Internal: run wake action (called by cron)")
parser.add_argument("--remove", action="store_const", const="remove", dest="mode", help="Remove cron timer")
parser.add_argument("--list", action="store_const", const="list", dest="mode", help="List active cron timers")
parser.add_argument("--status", action="store_const", const="status", dest="mode", help="Show status (cron + one-shot timers)")
parser.add_argument("--validate", action="store_const", const="validate", dest="mode", help="Validate sessions exist in zellij")
parser.add_argument("--interval", default=DEFAULT_INTERVAL, help="Interval (default: 1h). Formats: 30m, 1h, 90m, 2h, etc.")
parser.add_argument("--session", action="append", dest="session_list", help="Zellij session name (can repeat)")
parser.add_argument("--sessions", help="Comma-separated list of sessions")
parser.add_argument("--msg", default=f"Operator says CONTINUE. {AGENT_NICK} here, saying hi!", help="Wake message")
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without executing")
parser.set_defaults(mode="install")
return parser
# ─── Main ───────────────────────────────────────────────────────────────
def main() -> int:
parser = create_parser()
args = parser.parse_args()
# Combine sessions
sessions: List[str] = []
if args.sessions:
sessions.extend(parse_sessions(args.sessions))
if args.session_list:
sessions.extend(args.session_list)
# Setup logging
logger = setup_logging(args.debug)
# Validate
if len(sessions) == 0 and args.mode not in ("list", "status"):
logger.error("--session or --sessions required")
parser.print_help()
return 1
if args.mode == "succession" and args.count < 1:
logger.error("--count must be >= 1 for succession mode")
return 1
logger.info(f"=== pi_wake_agent {args.mode} ===")
logger.info(f"Interval: {args.interval} ({interval_to_human(args.interval)})")
logger.info(f"Sessions: {sessions}")
logger.info(f"Message: {args.msg}")
if args.mode == "succession":
logger.info(f"Count: {args.count}")
if args.dry_run:
logger.info("DRY RUN: no actions will be executed")
if args.dry_run:
logger.info("DRY RUN: would execute mode '%s' with sessions=%s, interval=%s, message='%s'", args.mode, sessions, args.interval, args.msg)
return 0
try:
if args.mode == "install":
install_cron(sessions, args.interval, args.msg, logger)
elif args.mode == "once":
run_once(sessions, args.interval, args.msg, logger)
elif args.mode == "daemon":
run_daemon(sessions, args.interval, args.msg, logger)
elif args.mode == "succession":
run_succession(sessions, args.interval, args.count, args.msg, logger)
elif args.mode == "run":
run_wake(sessions, args.msg, logger)
elif args.mode == "remove":
remove_cron(sessions, args.interval, logger)
elif args.mode == "list":
list_cron(logger)
elif args.mode == "status":
status(logger)
elif args.mode == "validate":
validate_sessions(sessions, logger)
else:
logger.error(f"Unknown mode: {args.mode}")
return 1
except Exception as e:
logger.error(f"Error: {e}")
if args.debug:
import traceback
traceback.print_exc()
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

51
pi_wake_agent.skill.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "pi_wake_agent",
"description": "Multi-agent wake-up timer with self-cron/daemon/succession modes. Sends doorbell injections via zellij and durable messages via h5i bus.",
"parameters": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["install", "once", "daemon", "succession", "run", "remove", "list", "status", "validate"],
"description": "Operation mode"
},
"interval": {
"type": "string",
"pattern": "^\\d+[hms]$",
"default": "1h",
"description": "Interval duration. Formats: 30m, 1h, 90m, 2h, 10s"
},
"session": {
"type": "array",
"items": { "type": "string" },
"description": "Zellij session name(s). Repeatable."
},
"sessions": {
"type": "string",
"description": "Comma-separated list of sessions"
},
"message": {
"type": "string",
"default": "Operator says CONTINUE. pi_nvnemo here, saying hi!",
"description": "Wake message sent to agent(s)"
},
"count": {
"type": "integer",
"minimum": 1,
"default": 1,
"description": "Number of runs for succession mode"
},
"dry_run": {
"type": "boolean",
"default": false,
"description": "Show what would be done without executing"
},
"debug": {
"type": "boolean",
"default": false,
"description": "Enable debug logging"
}
},
"required": ["mode"]
}
}

242
pi_wake_agent.skill.yaml Normal file
View File

@@ -0,0 +1,242 @@
# pi_wake_agent Skill Definition
# Universal Agent Skill Format (YAML)
# Compatible with: Anthropic, LangChain, OpenAI Functions, Custom Agents
skill:
name: "pi_wake_agent"
version: "1.1.0"
description: |
Multi-agent wake-up timer with self-cron/daemon/succession modes.
Sends doorbell injections via zellij and durable messages via h5i bus.
Designed for the DOLPHIN fleet (pi_nvnemo, cmd, mimo, codex, etc.).
author: "pi_nvnemo"
repository: "https://github.com/dolphinng5/pi_wake_agent"
branch: "tools/pi_wake_agent"
license: "MIT"
# ─── Installation ─────────────────────────────────────────────────────
installation:
method: "script"
path: "/mnt/dolphinng5_predict/pi_wake_agent.py"
requirements:
- python >= 3.8
- zellij (for terminal injections)
- h5i (for bus messaging, optional)
test_command: "python3 -m pytest test_pi_wake_agent.py -v"
# ─── Capabilities ─────────────────────────────────────────────────────
capabilities:
- name: "install_cron"
description: "Install recurring wake-up timer via system cron"
modes: ["install"]
- name: "once"
description: "One-shot wake after interval (no cron)"
modes: ["once"]
- name: "daemon"
description: "Long-lived process, no cron needed"
modes: ["daemon"]
- name: "succession"
description: "Run N times at interval, then self-clean (remove cron)"
modes: ["succession"]
- name: "run_wake"
description: "Internal: execute wake action (called by cron)"
modes: ["run"]
- name: "remove_cron"
description: "Remove cron timer entry"
modes: ["remove"]
- name: "list_cron"
description: "List active cron timers"
modes: ["list"]
- name: "status"
description: "Show cron + one-shot timer status"
modes: ["status"]
- name: "validate_sessions"
description: "Validate zellij sessions exist"
modes: ["validate"]
# ─── Parameters ───────────────────────────────────────────────────────
parameters:
mode:
type: "string"
enum: ["install", "once", "daemon", "succession", "run", "run", "remove", "list", "status", "validate"]
required: true
default: "install"
description: "Operation mode"
interval:
type: "string"
pattern: "^\\d+[hms]$"
default: "1h"
description: "Interval duration. Formats: 30m, 1h, 90m, 2h, 10s"
examples: ["1h", "30m", "90m", "2h", "10s"]
session:
type: "array"
items:
type: "string"
description: "Zellij session name(s). Repeatable flag."
examples: [["cc_UV_dev0_Fb"], ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]]
sessions:
type: "string"
description: "Comma-separated list of sessions (alternative to --session)"
examples: ["cc_UV_dev0_Fb,cc_UV_dev1_48"]
message:
type: "string"
default: "Operator says CONTINUE. pi_nvnemo here, saying hi!"
description: "Wake message sent to agent(s)"
count:
type: "integer"
minimum: 1
default: 1
description: "Number of runs for succession mode"
dry_run:
type: "boolean"
default: false
description: "Show what would be done without executing"
debug:
type: "boolean"
default: false
description: "Enable debug logging"
json:
type: "boolean"
default: false
description: "Output JSON for machine parsing"
config:
type: "string"
description: "Path to config file (JSON/YAML)"
# ─── Returns ──────────────────────────────────────────────────────────
returns:
type: "object"
properties:
exit_code:
type: "integer"
description: "0 = success, non-zero = error"
cron_entry:
type: "string"
description: "Installed cron line (for install mode)"
log_file:
type: "string"
value: "/tmp/pi_wake_agent.log"
message_sent:
type: "boolean"
description: "Whether wake message was sent"
# ─── Side Effects ─────────────────────────────────────────────────────
side_effects:
- "Modifies system crontab (install/remove modes)"
- "Injects text into zellij sessions (zellij write-chars)"
- "Sends h5i bus message to Fable (fire-and-forget)"
- "Writes to /tmp/pi_wake_agent.log (rotated at 10MB)"
- "Creates PID files in /tmp/pi_wake_agent_*.pid (once mode)"
# ─── h5i Bus Protocol ─────────────────────────────────────────────────
bus_protocol:
agent_id: "pi_nvnemo"
target: "Fable"
message_format: "[pi_nvnemo via zellij] {message} Run: h5i-bus msg inbox"
keypresses: 5
keypress_delay_ms: 1000
bus_message: "{message} (timer wakeup)"
timeout_ms: 5000
fire_and_forget: true
silently_ignore_failures: true
# ─── Zellij Integration ───────────────────────────────────────────────
zellij:
command_check: "zellij list-sessions"
injection_method: "zellij --session {session} action write-chars"
enter_keypress: "zellij --session {session} action write 13"
ansi_strip: true
session_exists_check: true
# ─── Cron Format ──────────────────────────────────────────────────────
cron:
comment_format: "pi_wake_agent:{sessions}:{interval}"
session_delimiter: ","
command_template: "cd /mnt/dolphinng5_predict && export H5I_AGENT=pi_nvnemo && /mnt/dolphinng5_predict/pi_wake_agent.py --run --sessions '{sessions}' --msg '{message}'"
# ─── Logging ──────────────────────────────────────────────────────────
logging:
file: "/tmp/pi_wake_agent.log"
rotation_mb: 10
max_files: 5
format: "[YYYY-MM-DD HH:MM:SS] [LEVEL] message"
levels: ["DEBUG", "INFO", "WARN", "ERROR"]
# ─── Examples ─────────────────────────────────────────────────────────
examples:
- name: "Recurring 1-hour wake for one session"
command: "pi_wake_agent.py --install --interval 1h --session cc_UV_dev0_Fb"
- name: "Multi-session 30-minute wake"
command: 'pi_wake_agent.py --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"'
- name: "One-shot in 2 hours"
command: 'pi_wake_agent.py --once --interval 2h --session cc_UV_dev0_Fb --msg "Time\'s up!"'
- name: "Run 3 times at 1-hour intervals, then self-clean"
command: 'pi_wake_agent.py --succession --count 3 --interval 1h --session cc_UV_dev0_Fb --msg "Scheduled wake"'
- name: "Daemon mode (long-lived process, no cron)"
command: 'pi_wake_agent.py --daemon --interval 1h --session cc_UV_dev0_Fb'
- name: "Remove timer"
command: 'pi_wake_agent.py --remove --session cc_UV_dev0_Fb --interval 1h'
- name: "List / status"
command: "pi_wake_agent.py --list"
command: "pi_wake_agent.py --status"
- name: "Validate sessions exist"
command: 'pi_wake_agent.py --validate --session cc_UV_dev0_Fb'
- name: "Dry run (show what would happen)"
command: 'pi_wake_agent.py --install --interval 1h --session test --msg "test" --dry-run'
# ─── Error Handling ───────────────────────────────────────────────────
error_handling:
- condition: "missing_session"
action: "return_error"
message: "--session or --sessions required"
exit_code: 1
- condition: "invalid_interval"
action: "return_error"
message: "Invalid interval format. Use like 1h, 30m, 90m, 2h"
exit_code: 1
- condition: "invalid_count"
action: "return_error"
message: "--count must be >= 1 for succession mode"
exit_code: 1
- condition: "cron_failure"
action: "log_warning_continue"
description: "Cron install/remove logs warning but continues"
- condition: "zellij_not_found"
action: "log_warning_continue"
description: "Session injection fails silently, bus message still sent"
- condition: "h5i_timeout"
action: "silent_ignore"
description: "h5i bus message failures are silently ignored (fire-and-forget)"
# ─── Testing ──────────────────────────────────────────────────────────
testing:
test_file: "test_pi_wake_agent.py"
test_count: 38
coverage:
- unit_interval_parsing
- unit_cron_comments
- unit_session_parsing
- integration_install_remove_list
- integration_once_short
- integration_succession_short
- edge_cases_invalid_intervals
- edge_cases_missing_sessions
- edge_cases_invalid_counts
run_command: "python3 -m pytest test_pi_wake_agent.py -v"
# ─── Metadata ─────────────────────────────────────────────────────────
metadata:
created: "2026-07-08"
updated: "2026-07-08"
maintainer: "pi_nvnemo"
tags: ["wake-timer", "cron", "zellij", "h5i", "multi-agent", "doorbell", "self-cleaning"]

View File

@@ -60,6 +60,13 @@ CH_WAL_TRUNCATE_BYTES = int(os.environ.get("CH_WAL_TRUNCATE_BYTES", str(64 * 102
CH_VACUUM_MIN_BYTES = int(os.environ.get("CH_VACUUM_MIN_BYTES", str(512 * 1024 * 1024))) CH_VACUUM_MIN_BYTES = int(os.environ.get("CH_VACUUM_MIN_BYTES", str(512 * 1024 * 1024)))
CH_VACUUM_MIN_FREE_RATIO = float(os.environ.get("CH_VACUUM_MIN_FREE_RATIO", "1.25")) CH_VACUUM_MIN_FREE_RATIO = float(os.environ.get("CH_VACUUM_MIN_FREE_RATIO", "1.25"))
CH_VACUUM_MIN_FREE_BYTES = int(os.environ.get("CH_VACUUM_MIN_FREE_BYTES", str(128 * 1024 * 1024))) CH_VACUUM_MIN_FREE_BYTES = int(os.environ.get("CH_VACUUM_MIN_FREE_BYTES", str(128 * 1024 * 1024)))
# Poison-row quarantine: a row CH permanently rejects (schema mismatch, bad
# value) must not head-of-line-block the spool forever. After this many
# failed attempts the row is retried INDIVIDUALLY (CH proven up first); if it
# still fails it moves to the dead_letter table for offline repair/replay.
# Incident 2026-06-12: one trade_events row with bars_held=-106 (UInt16
# column) was retried 3.2M times and jammed 18M rows behind it for 1.5 days.
CH_POISON_ATTEMPTS = int(os.environ.get("CH_POISON_ATTEMPTS", "200"))
# ─── Timestamp helpers ──────────────────────────────────────────────────────── # ─── Timestamp helpers ────────────────────────────────────────────────────────
@@ -202,6 +209,19 @@ class _CHWriter:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_queue_table ON queue(table_name, id)" "CREATE INDEX IF NOT EXISTS idx_queue_table ON queue(table_name, id)"
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dead_letter (
id INTEGER PRIMARY KEY,
table_name TEXT NOT NULL,
payload TEXT NOT NULL,
created_ts_us INTEGER NOT NULL,
attempts INTEGER NOT NULL,
dead_ts_us INTEGER NOT NULL,
last_error TEXT
)
"""
)
return conn return conn
_PUT_LOCK_TIMEOUT_S: float = 0.1 # max wait before dropping the row _PUT_LOCK_TIMEOUT_S: float = 0.1 # max wait before dropping the row
@@ -276,7 +296,7 @@ class _CHWriter:
with self._lock: with self._lock:
cur = self._conn.execute( cur = self._conn.execute(
"SELECT id, attempts FROM queue WHERE id IN (%s)" % ",".join("?" for _ in ids), "SELECT id, attempts FROM queue WHERE id IN (%s)" % ",".join("?" for _ in ids),
[int(_id) for _ in ids], [int(_id) for _id in ids],
) )
high_attempts = [(row[0], int(row[1]) + 1) for row in cur.fetchall() if int(row[1]) >= 1000] high_attempts = [(row[0], int(row[1]) + 1) for row in cur.fetchall() if int(row[1]) >= 1000]
self._conn.executemany( self._conn.executemany(
@@ -291,6 +311,65 @@ class _CHWriter:
row_id, attempt, row_id, attempt,
) )
def _ch_alive(self) -> bool:
"""True iff ClickHouse answers a trivial query — used to distinguish
'CH is down' (retry forever, quarantine nothing) from 'CH rejects this
specific row' (quarantine after CH_POISON_ATTEMPTS)."""
try:
req = urllib.request.Request(f"{CH_URL}/?query=SELECT+1", method="GET")
req.add_header("X-ClickHouse-User", CH_USER)
req.add_header("X-ClickHouse-Key", CH_PASS)
with urllib.request.urlopen(req, timeout=3) as resp:
return resp.status == 200
except Exception:
return False
def _quarantine_poison(self, table: str, items: List[Tuple[int, dict]]) -> None:
"""After a batch failure, isolate rows CH permanently rejects.
Only rows whose attempt count exceeds CH_POISON_ATTEMPTS are touched,
and only while CH itself is provably up. Each candidate is retried
alone: success → delivered+deleted; failure → moved to dead_letter
(payload preserved for offline repair/replay) so the spool can drain.
"""
ids = [row_id for row_id, _ in items]
if not ids:
return
with self._lock:
cur = self._conn.execute(
"SELECT id, attempts FROM queue WHERE id IN (%s)"
% ",".join("?" for _ in ids),
[int(i) for i in ids],
)
attempts_by_id = {int(r[0]): int(r[1]) for r in cur.fetchall()}
candidates = [
(row_id, payload) for row_id, payload in items
if attempts_by_id.get(int(row_id), 0) >= CH_POISON_ATTEMPTS
]
if not candidates:
return
if not self._ch_alive():
return # CH outage — nothing is poison, keep retrying the batch
for row_id, payload in candidates:
if self._post_rows(table, [payload]):
self._delete_ids([row_id])
continue
now = ts_us()
with self._lock:
self._conn.execute(
"INSERT OR REPLACE INTO dead_letter "
"(id, table_name, payload, created_ts_us, attempts, dead_ts_us, last_error) "
"SELECT id, table_name, payload, created_ts_us, attempts, ?, ? "
"FROM queue WHERE id=?",
(now, "rejected by CH while CH alive (see ch flush WARNINGs)", int(row_id)),
)
self._conn.execute("DELETE FROM queue WHERE id=?", (int(row_id),))
log.error(
"ch_writer[%s]: POISON ROW quarantined to dead_letter: id=%s table=%s "
"attempts=%d — spool unblocked; repair/replay offline",
self._db, row_id, table, attempts_by_id.get(int(row_id), -1),
)
def _queue_count(self) -> int: def _queue_count(self) -> int:
with self._lock: with self._lock:
row = self._conn.execute("SELECT count(*) FROM queue").fetchone() row = self._conn.execute("SELECT count(*) FROM queue").fetchone()
@@ -433,7 +512,7 @@ class _CHWriter:
raw = resp.read().decode("utf-8", errors="replace") raw = resp.read().decode("utf-8", errors="replace")
return [line for line in raw.splitlines() if line] return [line for line in raw.splitlines() if line]
def _existing_trade_keys(self, rows: List[dict]) -> set[Tuple[str, int]]: def _existing_trade_keys(self, rows: List[dict]) -> set[Tuple[str, str]]:
trade_ids: List[str] = [] trade_ids: List[str] = []
for row in rows: for row in rows:
trade_id = row.get("trade_id") trade_id = row.get("trade_id")
@@ -447,13 +526,14 @@ class _CHWriter:
return set() return set()
unique = sorted(set(trade_ids)) unique = sorted(set(trade_ids))
existing: set[Tuple[str, int]] = set() existing: set[Tuple[str, str]] = set()
chunk_size = 200 chunk_size = 200
for i in range(0, len(unique), chunk_size): for i in range(0, len(unique), chunk_size):
chunk = unique[i : i + chunk_size] chunk = unique[i : i + chunk_size]
quoted = ",".join("'" + tid.replace("'", "''") + "'" for tid in chunk) quoted = ",".join("'" + tid.replace("'", "''") + "'" for tid in chunk)
sql = ( sql = (
"SELECT trade_id, toInt64(toUnixTimestamp64Micro(ts)) " "SELECT trade_id, "
"ifNull(nullIf(event_id, ''), concat(toString(toInt64(toUnixTimestamp64Micro(ts))), ':', ifNull(exit_reason, ''))) "
f"FROM trade_events WHERE trade_id IN ({quoted}) FORMAT TSV" f"FROM trade_events WHERE trade_id IN ({quoted}) FORMAT TSV"
) )
try: try:
@@ -466,13 +546,24 @@ class _CHWriter:
parts = line.split("\t", 1) parts = line.split("\t", 1)
if len(parts) != 2: if len(parts) != 2:
continue continue
tid, ts_us_s = parts tid, event_key = parts
try: existing.add((tid, event_key))
existing.add((tid, int(ts_us_s)))
except Exception:
continue
return existing return existing
@staticmethod
def _trade_event_key(payload: dict) -> Tuple[str, str] | None:
tid = str(payload.get("trade_id", "") or "").strip()
if not tid:
return None
event_id = str(payload.get("event_id", "") or "").strip()
if event_id:
return (tid, event_id)
try:
ts_us_val = int(payload.get("ts"))
except Exception:
ts_us_val = -1
return (tid, f"{ts_us_val}:{payload.get('exit_reason', '')}")
def flush_once(self) -> int: def flush_once(self) -> int:
""" """
Drain a single batch from the local spool. Drain a single batch from the local spool.
@@ -494,38 +585,37 @@ class _CHWriter:
rows = [payload for _, payload in items] rows = [payload for _, payload in items]
if table == "trade_events": if table == "trade_events":
existing = self._existing_trade_keys(rows) existing = self._existing_trade_keys(rows)
if existing: seen = set(existing)
kept_ids: List[int] = [] kept_ids: List[int] = []
kept_rows: List[dict] = [] kept_rows: List[dict] = []
duplicate_ids: List[int] = [] duplicate_ids: List[int] = []
for row_id, payload in items: for row_id, payload in items:
tid = str(payload.get("trade_id", "")).strip() probe = self._trade_event_key(payload)
try: if probe is not None and probe in seen:
ts_us_val = int(payload.get("ts")) duplicate_ids.append(row_id)
except Exception:
ts_us_val = -1
if tid and ts_us_val >= 0 and (tid, ts_us_val) in existing:
duplicate_ids.append(row_id)
else:
kept_ids.append(row_id)
kept_rows.append(payload)
if duplicate_ids:
self._delete_ids(duplicate_ids)
log.warning(
"ch_writer[%s]: dropped %d duplicate trade_events rows by trade_id",
self._db,
len(duplicate_ids),
)
ids = kept_ids
rows = kept_rows
if not rows:
continue continue
kept_ids.append(row_id)
kept_rows.append(payload)
if probe is not None:
seen.add(probe)
if duplicate_ids:
self._delete_ids(duplicate_ids)
log.warning(
"ch_writer[%s]: dropped %d duplicate trade_events rows by stable event key",
self._db,
len(duplicate_ids),
)
ids = kept_ids
rows = kept_rows
if not rows:
continue
ok = self._post_rows(table, rows) ok = self._post_rows(table, rows)
if ok: if ok:
delivered += len(rows) delivered += len(rows)
self._delete_ids(ids) self._delete_ids(ids)
else: else:
self._bump_attempts(ids) self._bump_attempts(ids)
self._quarantine_poison(table, list(zip(ids, rows)))
self._maybe_maintain_spool() self._maybe_maintain_spool()
return delivered return delivered

View File

@@ -36,8 +36,6 @@ from .contracts import (
) )
from .journal import ClickHouseKernelJournal, KernelJournal, MemoryKernelJournal from .journal import ClickHouseKernelJournal, KernelJournal, MemoryKernelJournal
from .rust_backend import ExecutionKernel from .rust_backend import ExecutionKernel
from .bingx_venue import BingxVenueAdapter
from .launcher import DITAv2LauncherBundle, LauncherVenueMode, LauncherZincMode, build_launcher_bundle
from .projection import HazelcastProjection, build_position_state_row, build_projection from .projection import HazelcastProjection, build_position_state_row, build_projection
from .venue import VenueAdapter from .venue import VenueAdapter
from .mock_venue import MockVenueAdapter, MockVenueScenario from .mock_venue import MockVenueAdapter, MockVenueScenario
@@ -45,6 +43,28 @@ from .zinc_plane import InMemoryZincPlane, ZincPlane
from .real_zinc_plane import RealZincPlane, RealZincUnavailable from .real_zinc_plane import RealZincPlane, RealZincUnavailable
from .real_control_plane import RealZincControlPlane, RealZincUnavailable as RealZincControlUnavailable from .real_control_plane import RealZincControlPlane, RealZincUnavailable as RealZincControlUnavailable
def __getattr__(name: str):
if name == "BingxVenueAdapter":
from .bingx_venue import BingxVenueAdapter
return BingxVenueAdapter
if name in {"DITAv2LauncherBundle", "LauncherVenueMode", "LauncherZincMode", "build_launcher_bundle"}:
from .launcher import (
DITAv2LauncherBundle,
LauncherVenueMode,
LauncherZincMode,
build_launcher_bundle,
)
return {
"DITAv2LauncherBundle": DITAv2LauncherBundle,
"LauncherVenueMode": LauncherVenueMode,
"LauncherZincMode": LauncherZincMode,
"build_launcher_bundle": build_launcher_bundle,
}[name]
raise AttributeError(name)
__all__ = [ __all__ = [
"AccountProjection", "AccountProjection",
"AccountSnapshot", "AccountSnapshot",

View File

@@ -8,6 +8,7 @@ from enum import Enum
from typing import Any, Dict, Iterable, List, Optional from typing import Any, Dict, Iterable, List, Optional
import math import math
import time import time
import threading
from .contracts import TradeSide, TradeSlot, TradeStage from .contracts import TradeSide, TradeSlot, TradeStage
from .utils import safe_float from .utils import safe_float
@@ -59,42 +60,49 @@ class AccountProjection:
GIL guarantees single-field reference assignment is atomic, so readers GIL guarantees single-field reference assignment is atomic, so readers
that hold snap = kernel.account.snapshot before use see a consistent view. that hold snap = kernel.account.snapshot before use see a consistent view.
""" """
cur = self.snapshot with self._lock:
self.snapshot = AccountSnapshot( cur = self.snapshot
capital=kw.get("capital", cur.capital), self.snapshot = AccountSnapshot(
equity=kw.get("equity", cur.equity), capital=kw.get("capital", cur.capital),
realized_pnl=kw.get("realized_pnl", cur.realized_pnl), equity=kw.get("equity", cur.equity),
unrealized_pnl=kw.get("unrealized_pnl", cur.unrealized_pnl), realized_pnl=kw.get("realized_pnl", cur.realized_pnl),
open_positions=kw.get("open_positions", cur.open_positions), unrealized_pnl=kw.get("unrealized_pnl", cur.unrealized_pnl),
open_notional=kw.get("open_notional", cur.open_notional), open_positions=kw.get("open_positions", cur.open_positions),
fees_paid=kw.get("fees_paid", cur.fees_paid), open_notional=kw.get("open_notional", cur.open_notional),
trade_seq=kw.get("trade_seq", cur.trade_seq), fees_paid=kw.get("fees_paid", cur.fees_paid),
peak_capital=kw.get("peak_capital", cur.peak_capital), trade_seq=kw.get("trade_seq", cur.trade_seq),
capital_source=kw.get("capital_source", cur.capital_source), peak_capital=kw.get("peak_capital", cur.peak_capital),
e_wallet_balance=kw.get("e_wallet_balance", cur.e_wallet_balance), capital_source=kw.get("capital_source", cur.capital_source),
event_seq=kw.get("event_seq", cur.event_seq), e_wallet_balance=kw.get("e_wallet_balance", cur.e_wallet_balance),
) event_seq=kw.get("event_seq", cur.event_seq),
)
def __post_init__(self) -> None:
self._lock = threading.RLock()
def observe_slots(self, slots: Iterable[TradeSlot]) -> None: def observe_slots(self, slots: Iterable[TradeSlot]) -> None:
open_positions = 0 with self._lock:
open_notional = 0.0 open_positions = 0
unrealized_pnl = 0.0 open_notional = 0.0
for slot in slots: unrealized_pnl = 0.0
if slot.closed or slot.size <= 0: for slot in slots:
continue if slot.closed or slot.size <= 0:
if slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.POSITION_OPENED, TradeStage.ENTRY_WORKING, TradeStage.EXIT_WORKING}: continue
open_positions += 1 if slot.fsm_state in {TradeStage.POSITION_OPEN, TradeStage.POSITION_OPENED, TradeStage.ENTRY_WORKING, TradeStage.EXIT_WORKING}:
mark = safe_float(slot.entry_price, 0.0) open_positions += 1
mark = safe_float(slot.metadata.get("mark_price"), mark) mark = safe_float(slot.entry_price, 0.0)
open_notional += abs(slot.size) * abs(mark) mark = safe_float(slot.metadata.get("mark_price"), mark)
unrealized_pnl += float(slot.unrealized_pnl or 0.0) open_notional += abs(slot.size) * abs(mark)
self._replace_snapshot( unrealized_pnl += float(slot.unrealized_pnl or 0.0)
open_positions=open_positions, capital = self.snapshot.capital
open_notional=open_notional, peak_capital = self.snapshot.peak_capital
unrealized_pnl=unrealized_pnl, self._replace_snapshot(
equity=self.snapshot.capital + unrealized_pnl if math.isfinite(self.snapshot.capital + unrealized_pnl) else self.snapshot.capital, open_positions=open_positions,
peak_capital=max(self.snapshot.peak_capital, self.snapshot.capital) if open_notional > 0 and self.snapshot.capital > 0 else self.snapshot.peak_capital, open_notional=open_notional,
) unrealized_pnl=unrealized_pnl,
equity=capital + unrealized_pnl if math.isfinite(capital + unrealized_pnl) else capital,
peak_capital=max(peak_capital, capital) if open_notional > 0 and capital > 0 else peak_capital,
)
def anchor_to_exchange(self, wallet_balance: float, available_margin: float, event_seq: int) -> None: def anchor_to_exchange(self, wallet_balance: float, available_margin: float, event_seq: int) -> None:
"""Snap published capital to exchange wallet balance. """Snap published capital to exchange wallet balance.
@@ -106,39 +114,42 @@ class AccountProjection:
Guards: wallet_balance must be > 0 and finite (the zero-wb frame lesson Guards: wallet_balance must be > 0 and finite (the zero-wb frame lesson
from ACCOUNT_UPDATE frames with no USDT balance entry). from ACCOUNT_UPDATE frames with no USDT balance entry).
""" """
wb = safe_float(wallet_balance, 0.0) with self._lock:
if wb <= 0.0 or not math.isfinite(wb): wb = safe_float(wallet_balance, 0.0)
return if wb <= 0.0 or not math.isfinite(wb):
self.snapshot.capital = wb return
self.snapshot.e_wallet_balance = wb self.snapshot.capital = wb
self.snapshot.capital_source = "e_anchored" self.snapshot.e_wallet_balance = wb
self.snapshot.event_seq = int(event_seq) self.snapshot.capital_source = "e_anchored"
self.snapshot.equity = wb + self.snapshot.unrealized_pnl self.snapshot.event_seq = int(event_seq)
if not math.isfinite(self.snapshot.equity): self.snapshot.equity = wb + self.snapshot.unrealized_pnl
self.snapshot.equity = wb if not math.isfinite(self.snapshot.equity):
self.snapshot.peak_capital = max(self.snapshot.peak_capital, wb) self.snapshot.equity = wb
self.snapshot.peak_capital = max(self.snapshot.peak_capital, wb)
def settle(self, realized_pnl: float, fees: float = 0.0) -> None: def settle(self, realized_pnl: float, fees: float = 0.0) -> None:
rp = safe_float(realized_pnl, 0.0) with self._lock:
# Include fees in capital delta (today fees only accumulate in cur = self.snapshot
# fees_paid while published capital ignores them between reseeds). rp = safe_float(realized_pnl, 0.0)
net = rp - safe_float(fees, 0.0) # Include fees in capital delta (today fees only accumulate in
new_capital = safe_float(self.snapshot.capital + net, self.snapshot.capital) # fees_paid while published capital ignores them between reseeds).
if self.max_capital is not None: net = rp - safe_float(fees, 0.0)
new_capital = min(new_capital, self.max_capital) new_capital = safe_float(cur.capital + net, cur.capital)
new_capital = max(self.min_capital, new_capital) if self.max_capital is not None:
new_source = self.snapshot.capital_source new_capital = min(new_capital, self.max_capital)
if new_source == "e_anchored" and abs(net) > 1e-12: new_capital = max(self.min_capital, new_capital)
new_source = "k_bridged" new_source = cur.capital_source
new_fees = self.snapshot.fees_paid + safe_float(fees, 0.0) if new_source == "e_anchored" and abs(net) > 1e-12:
new_equity = new_capital + self.snapshot.unrealized_pnl new_source = "k_bridged"
if not math.isfinite(new_equity): new_fees = cur.fees_paid + safe_float(fees, 0.0)
new_equity = new_capital new_equity = new_capital + cur.unrealized_pnl
self._replace_snapshot( if not math.isfinite(new_equity):
capital=new_capital, capital_source=new_source, new_equity = new_capital
realized_pnl=self.snapshot.realized_pnl + rp, self._replace_snapshot(
fees_paid=new_fees, equity=new_equity, capital=new_capital, capital_source=new_source,
) realized_pnl=cur.realized_pnl + rp,
fees_paid=new_fees, equity=new_equity,
)
def to_account_event( def to_account_event(
self, self,
@@ -154,31 +165,32 @@ class AccountProjection:
bars_held: int = 0, bars_held: int = 0,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
self.snapshot.equity = self.snapshot.capital + self.snapshot.unrealized_pnl with self._lock:
return { self.snapshot.equity = self.snapshot.capital + self.snapshot.unrealized_pnl
"timestamp": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp), return {
"runtime_namespace": self.runtime_namespace, "timestamp": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
"strategy_namespace": self.strategy_namespace, "runtime_namespace": self.runtime_namespace,
"event_namespace": self.event_namespace, "strategy_namespace": self.strategy_namespace,
"actor_name": self.actor_name, "event_namespace": self.event_namespace,
"exec_venue": self.exec_venue, "actor_name": self.actor_name,
"data_venue": self.data_venue, "exec_venue": self.exec_venue,
"ledger_authority": self.ledger_authority, "data_venue": self.data_venue,
"capital": float(self.snapshot.capital), "ledger_authority": self.ledger_authority,
"equity": float(self.snapshot.equity), "capital": float(self.snapshot.capital),
"open_positions": int(self.snapshot.open_positions), "equity": float(self.snapshot.equity),
"current_open_notional": float(self.snapshot.open_notional), "open_positions": int(self.snapshot.open_positions),
"current_account_leverage": float(self.snapshot.leverage), "current_open_notional": float(self.snapshot.open_notional),
"trade_id": trade_id, "current_account_leverage": float(self.snapshot.leverage),
"asset": asset, "trade_id": trade_id,
"side": side.value, "asset": asset,
"reason": reason, "side": side.value,
"stage": stage.value, "reason": reason,
"pnl": float(pnl), "stage": stage.value,
"pnl_pct": float(pnl_pct), "pnl": float(pnl),
"bars_held": int(bars_held), "pnl_pct": float(pnl_pct),
"metadata": dict(metadata or {}), "bars_held": int(bars_held),
} "metadata": dict(metadata or {}),
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -311,6 +323,7 @@ class AccountProjectionV2:
self._min_capital = min_capital self._min_capital = min_capital
self._max_capital = max_capital self._max_capital = max_capital
self._cfg = reconcile_config or ReconcileConfig() self._cfg = reconcile_config or ReconcileConfig()
self._lock = threading.RLock()
# Running K-value accumulators # Running K-value accumulators
self._k_realized: float = 0.0 self._k_realized: float = 0.0
@@ -345,16 +358,18 @@ class AccountProjectionV2:
fee: float, fee: float,
realized_pnl: float, realized_pnl: float,
) -> None: ) -> None:
self._k_realized += _safe(realized_pnl) with self._lock:
self._k_fees += _safe(fee) self._k_realized += _safe(realized_pnl)
self._e_last_fill_price = _safe(fill_price) self._k_fees += _safe(fee)
self._e_last_fill_qty = _safe(fill_qty) self._e_last_fill_price = _safe(fill_price)
self._e_last_fill_fee = _safe(fee) self._e_last_fill_qty = _safe(fill_qty)
self._e_last_fill_realized = _safe(realized_pnl) self._e_last_fill_fee = _safe(fee)
self._e_last_fill_realized = _safe(realized_pnl)
def apply_funding(self, amount: float) -> None: def apply_funding(self, amount: float) -> None:
self._k_funding += _safe(amount) with self._lock:
self._e_last_funding = _safe(amount) self._k_funding += _safe(amount)
self._e_last_funding = _safe(amount)
def apply_balance_update( def apply_balance_update(
self, self,
@@ -364,13 +379,15 @@ class AccountProjectionV2:
used_margin: float, used_margin: float,
maint_margin: float, maint_margin: float,
) -> None: ) -> None:
self._e_wallet_balance = _safe(wallet_balance) with self._lock:
self._e_avail_margin = _safe(available_margin) self._e_wallet_balance = _safe(wallet_balance)
self._e_used_margin = _safe(used_margin) self._e_avail_margin = _safe(available_margin)
self._e_maint_margin = _safe(maint_margin) self._e_used_margin = _safe(used_margin)
self._e_maint_margin = _safe(maint_margin)
def apply_position_update(self, positions: List[EPosition]) -> None: def apply_position_update(self, positions: List[EPosition]) -> None:
self._e_positions = list(positions) with self._lock:
self._e_positions = list(positions)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Snapshot construction (called after each ingestion step) # Snapshot construction (called after each ingestion step)
@@ -382,21 +399,24 @@ class AccountProjectionV2:
slots: Iterable[TradeSlot], slots: Iterable[TradeSlot],
ts: Optional[float] = None, ts: Optional[float] = None,
) -> AccountSnapshotV2: ) -> AccountSnapshotV2:
self._event_seq += 1 with self._lock:
snap = self._build(self._event_seq, source_event_id, list(slots), ts or time.time()) self._event_seq += 1
self._snapshot = snap snap = self._build(self._event_seq, source_event_id, list(slots), ts or time.time())
return snap self._snapshot = snap
return snap
@property @property
def snapshot(self) -> AccountSnapshotV2: def snapshot(self) -> AccountSnapshotV2:
return self._snapshot with self._lock:
return self._snapshot
@property @property
def k_capital(self) -> float: def k_capital(self) -> float:
raw = self._seed + self._k_realized - self._k_fees - self._k_funding with self._lock:
if self._max_capital is not None: raw = self._seed + self._k_realized - self._k_fees - self._k_funding
raw = min(raw, self._max_capital) if self._max_capital is not None:
return max(self._min_capital, raw) raw = min(raw, self._max_capital)
return max(self._min_capital, raw)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal helpers # Internal helpers

View File

@@ -8,6 +8,8 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import threading
from .control import BackendMode, ControlPlane, ControlUpdate, KernelControlSnapshot, KernelMode, KernelVerbosity from .control import BackendMode, ControlPlane, ControlUpdate, KernelControlSnapshot, KernelMode, KernelVerbosity
_ZINC_ADAPTER_PATH = Path(__file__).resolve().parents[3] / "zinc" / "adapters" / "python" _ZINC_ADAPTER_PATH = Path(__file__).resolve().parents[3] / "zinc" / "adapters" / "python"
@@ -70,6 +72,7 @@ class RealZincControlPlane(ControlPlane):
require_real_zinc() require_real_zinc()
base = prefix.strip("/").replace("/", "_") base = prefix.strip("/").replace("/", "_")
self.region_name = f"{base}_control" self.region_name = f"{base}_control"
self._lock = threading.RLock()
self._seq = 0 self._seq = 0
self._snapshot = KernelControlSnapshot() self._snapshot = KernelControlSnapshot()
if create: if create:
@@ -86,21 +89,24 @@ class RealZincControlPlane(ControlPlane):
self.region.close() self.region.close()
def read(self) -> KernelControlSnapshot: def read(self) -> KernelControlSnapshot:
payload = _decode_packet(self.region.as_buffer()) with self._lock:
control = payload.get("control") if isinstance(payload, dict) else None payload = _decode_packet(self.region.as_buffer())
if not isinstance(control, dict): control = payload.get("control") if isinstance(payload, dict) else None
if not isinstance(control, dict):
return self._snapshot
self._snapshot = KernelControlSnapshot(**control)
return self._snapshot return self._snapshot
self._snapshot = KernelControlSnapshot(**control)
return self._snapshot
def update(self, update: ControlUpdate) -> KernelControlSnapshot: def update(self, update: ControlUpdate) -> KernelControlSnapshot:
self._snapshot = update.apply(self.read()) with self._lock:
self._seq += 1 self._snapshot = update.apply(self.read())
self._write_region(self._seq, self._snapshot.as_dict()) self._seq += 1
return self._snapshot self._write_region(self._seq, self._snapshot.as_dict())
return self._snapshot
def mirror(self) -> Dict[str, Any]: def mirror(self) -> Dict[str, Any]:
return self._snapshot.as_dict() with self._lock:
return self._snapshot.as_dict()
def wait(self, timeout_ms: int = 1000) -> bool: def wait(self, timeout_ms: int = 1000) -> bool:
try: try:

View File

@@ -258,14 +258,16 @@ class RealZincPlane:
self._write_region(self.state_region, self._state_seq, payload) self._write_region(self.state_region, self._state_seq, payload)
def read_slots(self) -> List[TradeSlot]: def read_slots(self) -> List[TradeSlot]:
payload = _decode_packet(self.state_region.as_buffer()) with self._lock:
slots = payload.get("slots", []) if isinstance(payload, dict) else [] payload = _decode_packet(self.state_region.as_buffer())
return [_slot_from_payload(slot) for slot in sorted(slots, key=lambda row: int(row.get("slot_id", 0)))] slots = payload.get("slots", []) if isinstance(payload, dict) else []
return [_slot_from_payload(slot) for slot in sorted(slots, key=lambda row: int(row.get("slot_id", 0)))]
def read_intents(self) -> List[Dict[str, Any]]: def read_intents(self) -> List[Dict[str, Any]]:
payload = _decode_packet(self.intent_region.as_buffer()) with self._lock:
items = payload.get("items", []) if isinstance(payload, dict) else [] payload = _decode_packet(self.intent_region.as_buffer())
return list(items) items = payload.get("items", []) if isinstance(payload, dict) else []
return list(items)
def update_control(self, control: KernelControlSnapshot) -> None: def update_control(self, control: KernelControlSnapshot) -> None:
with self._lock: with self._lock:
@@ -274,11 +276,12 @@ class RealZincPlane:
self._write_region(self.control_region, self._control_seq, {"control": control.as_dict()}) self._write_region(self.control_region, self._control_seq, {"control": control.as_dict()})
def read_control(self) -> KernelControlSnapshot: def read_control(self) -> KernelControlSnapshot:
payload = _decode_packet(self.control_region.as_buffer()) with self._lock:
control = payload.get("control") if isinstance(payload, dict) else None payload = _decode_packet(self.control_region.as_buffer())
if not isinstance(control, dict): control = payload.get("control") if isinstance(payload, dict) else None
return self._control_cache if not isinstance(control, dict):
return KernelControlSnapshot(**control) return self._control_cache
return KernelControlSnapshot(**control)
def wait_on_state(self, timeout_ms: int = 1000) -> bool: def wait_on_state(self, timeout_ms: int = 1000) -> bool:
return bool(self.state_region.wait(timeout_ms)) return bool(self.state_region.wait(timeout_ms))

View File

@@ -13,6 +13,7 @@ from __future__ import annotations
import math import math
import sys import sys
from concurrent.futures import ThreadPoolExecutor
sys.path.insert(0, "/mnt/dolphinng5_predict") sys.path.insert(0, "/mnt/dolphinng5_predict")
import pytest import pytest
@@ -324,7 +325,46 @@ class TestReplayDeterminism:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 7. V1 backward compatibility (AccountProjection must be untouched) # 7. Concurrency guard
# ---------------------------------------------------------------------------
class TestConcurrencyGuard:
def test_apply_fill_is_serialized(self):
proj = _proj(10_000.0)
n_threads = 16
per_thread = 250
total_fee = 0.0
total_realized = 0.0
def _worker(tid: int) -> tuple[float, float]:
local_fee = 0.0
local_realized = 0.0
for i in range(per_thread):
realized = float(tid * per_thread + i)
fee = float((i % 5) * 0.1)
proj.apply_fill(
fill_price=100.0,
fill_qty=1.0,
fee=fee,
realized_pnl=realized,
)
local_fee += fee
local_realized += realized
return local_realized, local_fee
with ThreadPoolExecutor(max_workers=n_threads) as ex:
for realized, fee in ex.map(_worker, range(n_threads)):
total_realized += realized
total_fee += fee
snap = _snap(proj)
assert snap.k.realized_pnl == pytest.approx(total_realized)
assert snap.k.fees_paid == pytest.approx(total_fee)
assert snap.k.capital == pytest.approx(10_000.0 + total_realized - total_fee)
# ---------------------------------------------------------------------------
# 8. V1 backward compatibility (AccountProjection must be untouched)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestV1Compat: class TestV1Compat:

View File

@@ -115,7 +115,8 @@ class TestASExConcurrency:
for f in as_completed([ex.submit(_f, i) for i in [0,1]] + [ex.submit(_fu, i) for i in [0,1]]): for f in as_completed([ex.submit(_f, i) for i in [0,1]] + [ex.submit(_fu, i) for i in [0,1]]):
f.result(timeout=60) f.result(timeout=60)
assert p._backend._proj._k_realized == pytest.approx(200.0) assert p._backend._proj._k_realized == pytest.approx(200.0)
assert p._backend._proj._k_funding == pytest.approx(100.0) # 2 workers x 50 calls x 0.5 = 50.0; exact total proves zero lost updates
assert p._backend._proj._k_funding == pytest.approx(50.0)
p.close(); _clean() p.close(); _clean()

View File

@@ -34,7 +34,7 @@ EXPECTED_TABLES = {
"status_snapshots", "trade_events", "v7_decision_events", "status_snapshots", "trade_events", "v7_decision_events",
"adaptive_exit_shadow", "fee_settled_events", "adaptive_exit_shadow", "fee_settled_events",
"sc_bucket_gauge_shadow", "sc_threshold_advisor_shadow", "sc_bucket_gauge_shadow", "sc_threshold_advisor_shadow",
"violet_feed_divergence", "violet_feed_divergence", "violet_decisions",
} }

View File

@@ -0,0 +1,105 @@
# BEADS as PASS Tracker — Evaluation & Recommendation
**Author:** pi_nvnemo (UV Overseer)
**Date:** 2026-07-08
**Context:** UV_OVERSEER_CHARTER__PI.md §6 task — evaluate beads vs bus+doc for PASS board
---
## Current State
| Tracker | Status |
|---------|--------|
| **h5i bus** | Active — dispatch, ACK, status updates |
| **Status doc** | Not yet created (charter says "track PASSes on the bus + a short status doc") |
| **beads** | Installed, `.beads/` exists at repo root (prefix `dp`), 1 existing PRODGREEN issue |
---
## What Beads Gives Us Over Bus+Doc
| Capability | h5i Bus + Doc | Beads |
|------------|---------------|-------|
| **Dependency graph** | Manual (doc) | Native (`br dep add`, `br graph`) |
| **Task hierarchy** | Flat (doc sections) | Epic → child beads (parent/child) |
| **State machine** | Manual (doc) | Enforced (open → in_progress → closed) |
| **Acceptance criteria** | Doc prose | Structured fields (`acceptance_criteria`, `test_command`) |
| **Audit trail** | Bus history + doc edits | Immutable JSONL + SQL + `br audit` |
| **Handoff protocol** | Informal | Formal (`br audit --message`, `br ready`) |
| **Multi-agent isolation** | Bus channels | Separate workspace per refactor stream |
| **Low-skill agent onboarding** | Ad-hoc | Bounded task template + dependency chain |
| **Query/Filter** | grep/awk | `br ready`, `br list`, `br status`, SQL |
| **Backup/Sync** | Git + manual | `br sync`, `br backup` |
**Verdict:** Beads adds **structured task management** that the bus+doc lacks — critical for multi-PASS dependency chains (PASS-P → PASS-A → PASS-S → PASS-B/X).
---
## PASS → Bead Mapping
| PASS | Bead Type | Suggested ID | Parent |
|------|-----------|--------------|--------|
| **PASS-P** (Pulse Landing) | Epic | `UV_PASS-P` | — |
| ├─ Copy `prod/uv_pulse_host/` | Task | `UV_PASS-P.1` | `UV_PASS-P` |
| ├─ Add `.gitignore` (exclude `target/`) | Task | `UV_PASS-P.2` | `UV_PASS-P` |
| ├─ Cert conveyor commit | Task | `UV_PASS-P.3` | `UV_PASS-P` |
| ├─ Soak DARK + TUI heartbeat | Task | `UV_PASS-P.4` | `UV_PASS-P` |
| **PASS-A** (Account Region) | Epic | `UV_PASS-A` | — |
| ├─ Phase 0: Contracts + in-mem | Task | `UV_PASS-A.1` | `UV_PASS-A` |
| ├─ Phase 1: Real shm + hardened reader | Task | `UV_PASS-A.2` | `UV_PASS-A` |
| ├─ Phase 2: ASEx publish | Task | `UV_PASS-A.3` | `UV_PASS-A` |
| ├─ Phase 3: Capital provider | Task | `UV_PASS-A.4` | `UV_PASS-A` |
| **PASS-S** (Sizing Seam) | Epic | `UV_PASS-S` | — |
| **PASS-B** (Host Brain) | Epic | `UV_PASS-B` | — |
| **PASS-X** (Tick Exits) | Epic | `UV_PASS-X` | — |
**Dependency Chain:**
```
UV_PASS-P → UV_PASS-A → UV_PASS-S → UV_PASS-B → UV_PASS-X
```
---
## Smallest Viable Setup
```bash
# 1. Create dedicated UV workspace (isolate from PRODGREEN)
mkdir -p /mnt/dolphinng5_predict/uv/.beads
export BEADS_DIR=/mnt/dolphinng5_predict/uv/.beads
# 2. Initialize
br where # confirms workspace
# 3. Create PASS-P epic + children
br create --title "PASS-P: Pulse Landing" --type epic --id UV_PASS-P
br create --title "Copy prod/uv_pulse_host/ from /mnt/vp-PASS9" --parent UV_PASS-P --type task --acceptance "Directory copied, target/ excluded" --test "ls prod/uv_pulse_host/ && ! ls prod/uv_pulse_host/target/" --id UV_PASS-P.1
br create --title "Add .gitignore excluding target/" --parent UV_PASS-P --type task --acceptance "target/ ignored by git" --test "git check-ignore prod/uv_pulse_host/target/" --id UV_PASS-P.2
br create --title "Cert conveyor commit + integrator review" --parent UV_PASS-P --type task --acceptance "Commit on main, integrator signed" --test "git log --oneline -1 prod/uv_pulse_host/" --id UV_PASS-P.3
br create --title "Soak DARK + TUI heartbeat + STALE" --parent UV_PASS-P --type task --acceptance "TUI renders live rate/AGE/STALE, RSS flat ≥4h" --test "TUI smoke test + log review" --id UV_PASS-P.4
# 4. Link to bus for dispatch notifications
# (beads = source of truth; bus = real-time signal)
```
---
## Recommendation
**ADOPT BEADS for PASS tracking** with the following protocol:
1. **Beads = Source of Truth** — all PASS state, dependencies, acceptance criteria, audit trail
2. **h5i Bus = Real-time Signal** — dispatch, ACK, status pings, escalation (what we already do)
3. **Status Doc = Snapshot** — auto-generated from beads weekly or on demand (`br status > PASS_BOARD.md`)
**Migration Path:**
- Week 1: Create UV workspace, populate PASS-P + PASS-A epics/children
- Week 1: Run dual-track (beads + bus) — validate no drift
- Week 2: Deprecate manual status doc; auto-generate from `br status`
**Why not bus+doc alone?** The PASS chain has 5 epics with 15+ children, strict dependencies, and must survive agent rotation. Beads enforces what the charter demands: "keep a live board; chase stalls; escalate blocked specs."
---
## Next Action
If approved: I'll initialize `/mnt/dolphinng5_predict/uv/.beads`, populate PASS-P epic + children, and link dispatch messages to bead IDs.

View File

@@ -0,0 +1,659 @@
# PINK Forensics — Dual Leverage Architecture (2026 Search Results)
**Date:** 2026-07-06
**Agent:** pi_nvnemo
**Trigger:** Operator request — locate the authoritative dual-leverage spec
---
## Executive Summary
The DOLPHIN system implements a **strict dual-leverage architecture** separating two distinct leverage concepts that must NEVER be conflated:
| Layer | Name | Range | Purpose | Set By |
|-------|------|-------|---------|--------|
| **Internal** | **Conviction Leverage** (our_leverage) | 0.5 9.0 (fractional) | Sizes QUANTITY: `notional = capital × 0.20 × conviction`, `qty = notional / entry_price` | Strategy / sizer (`esf_alpha_orchestrator`, `AlphaBetSizer`) |
| **Venue** | **Exchange Leverage** (xlev) | 1 3 (integer) | Controls MARGIN: `margin = notional / exchange_lev` sent to BingX API | Venue boundary mapper (`prod/bingx/leverage.py`) |
**PnL is ALWAYS leverage-free**: `qty × Δprice` (side-signed). Exchange leverage only affects collateral lockup.
---
## Authoritative Source Files (Bit-Identity Required)
### 1. `prod/bingx/leverage.py` — **THE SINGLE SOURCE OF TRUTH** (83 lines, no callers)
```python
CONVICTION_MIN = 0.5
CONVICTION_MAX = 9.0
EXCHANGE_LEV_MIN = 1
EXCHANGE_LEV_MAX = 3
LEVERAGE_MAPPING_RULE = "round_half_even_linear_0.5_to_9.0_to_1_to_exchange_cap"
def map_internal_conviction_to_exchange_leverage_target(internal, *, exchange_min, exchange_max) -> float:
# clamp internal to [0.5, 9.0]
# linear: exchange_min + (internal - 0.5)/(9.0 - 0.5) * (exchange_max - exchange_min)
# returns FLOAT target (pre-round)
def normalize_bingx_leverage_value(leverage, *, exchange_min, exchange_max) -> int:
# ROUND_HALF_EVEN (banker's: 1.5→2, 2.5→2, 3.5→4) + clamp to [exchange_min, exchange_max]
def map_internal_conviction_to_exchange_leverage(internal, *, exchange_min, exchange_max) -> int:
# = normalize_bingx_leverage_value(map_..._target(internal), ...)
# FINAL integer sent to BingX API
```
### 2. `prod/clean_arch/runtime/pink_direct.py:_hz_publish()` (line ~909)
```python
def _hz_publish(self, slot_dict: dict, acc: dict) -> None:
"""Fire-and-forget Hz write after any kernel state change.
Computes system leverage (our_leverage = notional/capital) for the Hz
snapshot — PINK/BLUE dual-leverage invariant: system leverage reflects real
margin utilisation; exchange leverage (1-3x cap) is set at BingX API level.
"""
size = float(slot_dict.get("size") or 0.0)
ep = float(slot_dict.get("entry_price") or 0.0)
capital = float(acc.get("capital") or 0.0)
our_leverage = (size * ep / capital) if capital > 1e-10 else 0.0
self.hz_state_writer.write_engine_snapshot(
slot_dict, acc,
posture=self._last_posture,
our_leverage=our_leverage, # <-- CONVICTION leverage published to Hz
scan_number=self._last_scan_number,
vel_div=self._last_vel_div,
vol_ok=self._last_vol_ok,
)
```
---
## Spec Documents (Chronological)
### A. `prod/docs/FRACTIONAL_LEVERAGE_TO_BINGX_FIX.md` (2025-04-24)
**Origin story** — CRITICAL bug: exchange leverage was hardcoded to 1x, ignoring per-trade fractional leverage.
- "The system correctly separates leverage into two roles"
- Fractional leverage → affects quantity (how many contracts)
- Exchange leverage → affects margin (how much collateral)
- Fix: CEIL rounding for exchange leverage (`ceil(fractional_lev)` clamped to [1,9])
### B. `prod/docs/PINK_ACCOUNTING_EXEC_FIX.md` (2026-06-11)
**Forensic incident** — FET short settled at +$164 but kernel booked $5,990.90.
**HARD INVARIANT (§0):**
> **Dual leverage**: `slot.size` = exchange quantity; `slot.leverage` = exchange leverage (13x cap, set at BingX API); *our*-leverage (conviction) = `size × entry_price / capital`, computed **only** at `pink_direct._hz_publish` (line ~911). PnL is therefore **leverage-free**: `qty × Δprice`, side-signed. Do not touch the conviction→exchange mapping (`round_half_even_linear_0.5_to_9.0_to_1_to_exchange_cap`) or `target_size` computation.
### C. `prod/docs/VIOLET_SUB_SPEC__L3_EXCHANGE_LEVERAGE.md` (2026-06-15)
**VIOLET L3 wrapper spec** — "WRAP, DON'T REIMPLEMENT"
- V-TYPES boundary: `ConvictionLeverage` (Annotated float) → `ExchangeLeverage` (Annotated int ≥1)
- `VioletExchangeLeverage` class wraps `prod/bingx/leverage.py` functions exactly
- Gate: MC bit-identity @ N≥1e6 vs real `leverage.py` output
- Zero shared-file edits; bit-identity is the contract
### D. `prod/docs/VIOLET_V3_FINDINGS.md` §2 (2026-06-15)
> **DUAL-LEVERAGE:** conviction leverage sizes the QUANTITY (internal); exchange leverage mapped at venue boundary via `prod/bingx/leverage.py` `map_internal_conviction_to_exchange_leverage_target` (round_half_even linear 0.59.0 → 1..cap; PINK/VIOLET use max-3× **linear** translator).
### E. `prod/docs/PRODGREEN_TUI_AND_LEVERAGE_OBSERVABILITY_SPEC.md` (2026)
**TUI display labels:**
- `cm:` for conviction multiplier
- `xlev:` for exchange leverage
- `lev:` legacy (visually secondary)
---
## Key Terms / Vocabulary
| Term | Meaning | Where Defined |
|------|---------|---------------|
| `conviction leverage` / `our_leverage` | Internal fractional [0.5, 9.0], sizes quantity | `pink_direct.py:_hz_publish` |
| `exchange leverage` / `xlev` | Integer [1,3] sent to BingX API | `leverage.py`, `pink_direct.py` |
| `dual-leverage doctrine` | The separation principle | `PINK_ACCOUNTING_EXEC_FIX.md` §0 |
| `round_half_even` | Banker's rounding (x.5 → even) | `leverage.py`, `VIOLET_SUB_SPEC__L3` |
| `map_internal_conviction_to_exchange_leverage` | The mapper function | `leverage.py` |
| `target_exchange_leverage` | Float pre-round value | `VIOLET_SUB_SPEC__L3` |
| `exchange_leverage` | Final int sent to venue | `VIOLET_SUB_SPEC__L3` |
| `notional` | `capital × 0.20 × conviction` | `esf_alpha_orchestrator.py` |
| `base_fraction` | 0.20 (constant in BLUE) | `VIOLET_V3_FINDINGS.md` §2 |
---
## Execution Flow (PINK → BingX)
```
1. BLUE/VIOLET sizer computes conviction ∈ [0.5, 9.0]
2. notional = capital × 0.20 × conviction
3. quantity = notional / entry_price
4. At venue boundary (pink_direct / execution.py):
target = map_internal_conviction_to_exchange_leverage_target(conviction) # float
xlev = normalize_bingx_leverage_value(target) # int [1,3]
5. BingX API: POST /leverage {"symbol": "...", "side": "BOTH", "leverage": xlev}
6. Margin locked = notional / xlev
7. PnL calculation: qty × (exit_price - entry_price) [NO leverage factor]
8. Hz snapshot publishes: our_leverage = (size × entry_price) / capital
```
---
## VIOLET Integration Points
| Component | Role | File |
|-----------|------|------|
| `VioletExchangeLeverage` | V-TYPES wrapper, bit-identity gated | `prod/clean_arch/violet/exchange_leverage.py` |
| `TradeabilityProjection` | L1→L3 projector (conviction → xlev + margin) | `prod/clean_arch/violet/tradeability.py` (Task 6) |
| `ShadowDecision` | L1 output carrying `conviction_leverage` | `decision_engine.py` |
---
## Mutation Litmus (What Breaks If Conflated)
| Mutation | Expected Test Failure |
|----------|----------------------|
| Use `exchange_leverage` in PnL calc | `test_pink_ditav2_accounting_invariants.py` — realized PnL 3× inflated |
| Use `conviction` as BingX leverage | Margin rejection or over-leverage (BingX max 3× for PINK) |
| Round-half-up instead of half-even | `VIOLET_SUB_SPEC__L3` gate: 2.5→3 instead of 2, bit-identity fails |
| Drop the clamp to [1,3] | BingX API rejects leverage >3 for PINK symbols |
---
## Related Files to Audit (Per Search)
- `prod/clean_arch/runtime/pink_direct.py``_hz_publish`, `_exec_submit`, intent leverage flow
- `prod/bingx/execution.py``_ensure_leverage`, `_normalize_bingx_leverage_value` (legacy CEIL, not ROUND_HALF_EVEN)
- `prod/clean_arch/violet/exchange_leverage.py` — VIOLET L3 wrapper
- `prod/clean_arch/violet/tradeability.py` — L3 projector (if built)
- `esf_alpha_orchestrator.py` — 5-factor conviction composition (base × DC × ACB × OB × EsoF)
- `alpha_wrappers.py` — VIOLET V-TYPES for `ConvictionLeverage`
- `prod/tests/test_pink_ditav2_accounting_invariants.py` — Accounting tests
- `prod/tests/test_violet_exchange_leverage.py` — VIOLET L3 gate tests
---
## Operator Directives (Binding)
1. **NEVER reimplement `leverage.py` logic** — wrap it (VIOLET L3 spec, non-negotiable)
2. **PnL is leverage-free**`qty × Δprice` only (PINK_ACCOUNTING_EXEC_FIX.md HARD INVARIANT)
3. **Bit-identity gate** — VIOLET output must `==` `prod/bingx/leverage.py` output exactly (MC N≥1e6)
4. **ROUND_HALF_EVEN** — not round-half-up, not CEIL, not floor (banker's rounding)
5. **Conviction sizes qty; exchange lev sizes margin** — the two paths are orthogonal after notional
---
## Search Provenance
Found via: `grep -r "dual.leverage\|our.*leverage.*exchange\|conviction.*multiplier\|map_internal_conviction_to_exchange" /mnt/dolphinng5_predict/prod/docs --include="*.md"`
Key hits: `PINK_ACCOUNTING_EXEC_FIX.md`, `VIOLET_SUB_SPEC__L3_EXCHANGE_LEVERAGE.md`, `FRACTIONAL_LEVERAGE_TO_BINGX_FIX.md`, `VIOLET_V3_FINDINGS.md`, `PRODGREEN_TUI_AND_LEVERAGE_OBSERVABILITY_SPEC.md`, `INDEX_REVIEW_alpha_engine.md`, `UV_TASK_T19_UV_CLOCK_HOST.md`
---
## Next Search Vectors (Operator Guidance)
- Search `esf_alpha_orchestrator.py` for 5-factor conviction composition
- Search `alpha_wrappers.py` for V-TYPES `ConvictionLeverage` definition
- Search `prod/bingx/execution.py` for legacy CEIL vs ROUND_HALF_EVEN divergence
- Trace `dolphin_actor.py` tag `lev:X.XX` → execution path
---
## Additional Findings (Extended Search)
### 1. `prod/bingx/sizing_mode.py` — Sizing Mode Contract
- Three modes: `engine` (default, no BingX payload), `testnet`, `live_market`
- `build_split_sizing_payload()` emits BingX-ready sizing with `exchange_leverage_cap`
- Delegates to `prod.utils.trade_sizing_bridge.build_engine_ready_sizing()`
### 2. `prod/utils/trade_sizing_bridge.py` — Engine-Ready Sizing Translation
**Core function:** `size_trade_from_sizing_lev()` — the complete translation pipeline:
```python
# Input: sizing_lev (conviction), capital, mark_price, etc.
# Output: TradeSizingResult with:
# - internal_leverage: cubic-convex conviction ∈ [0.5, 9.0]
# - exchange_leverage_target: float (pre-round, linear map)
# - exchange_leverage: int (ROUND_HALF_EVEN + clamp to [1, exchange_cap])
# - effective_notional: min(venue_cap, margin_budget × exchange_leverage)
# - quantity: floor(effective_notional / mark_price / step_size) × step_size
# - margin_to_capital, notional_to_capital ratios
```
**Key constants:**
- `DEFAULT_BINGX_EXCHANGE_LEVERAGE_CAP = 3`
- `DEFAULT_MIN_INTERNAL_LEVERAGE = 0.5`
- `DEFAULT_MAX_INTERNAL_LEVERAGE = 9.0`
- `DEFAULT_LEVERAGE_CONVEXITY = 3.0` (cubic!)
- `DEFAULT_MARGIN_BUDGET_FRACTION = 0.20`
**Convexity note:** The "cubic" in "max-3× cubic translator" refers to the **conviction sizing curve** (`strength_cubic = clamp(...)³`), NOT the exchange leverage mapping. The exchange mapping is **linear** with **ROUND_HALF_EVEN**.
### 3. `prod/clean_arch/adapters/bingx_direct.py` — DITAv2 Venue Adapter
- Uses `map_internal_conviction_to_exchange_leverage()` from `prod.bingx.leverage`
- Default `exchange_leverage_cap = 3`
- Applies leverage per-symbol via cache `_configured_leverage`
### 4. `prod/clean_arch/dita_v2/blue_parity.py` — BLUE Parity Wrapper
**DUAL-LEVERAGE INVARIANT (docstring):**
> "the fractional leverage produced here is STRATEGY conviction — it sizes the quantity. At-exchange leverage is derived from it at the venue boundary via map_internal_conviction_to_exchange_leverage() (linear [0.5, 9.0] → [1, cap], bankers rounding, security cap)."
### 5. `prod/clean_arch/dita_v2/test_blue_parity.py` — Parity Tests
**TestConvictionToExchangeLeverage class validates:**
```python
m(0.5) == 1 # conviction floor → exchange floor
m(9.0) == 3 # conviction ceiling → exchange cap (3)
m(4.75) == 2 # exact midpoint [0.5, 9.0] → target 2.0 → round_half_even(2.0) = 2
m(0.1) == 1 # clamped below conviction floor
m(50.0) == 3 # clamped above conviction ceiling
# monotonic: {1, 2, 3} across conviction range
```
### 6. `prod/docs/NAUTILUS_DOLPHIN_SPEC.md` — Sizing Formula
```
leverage = min_leverage + (max_leverage - min_leverage) × (signal_strength)^leverage_convexity
# leverage_convexity = 3.0 → CUBIC
strength_cubic = clamp((threshold - vel_div) / (threshold - extreme), 0, 1) ** 3
```
### 7. `prod/docs/SYSTEM_BIBLE_v7.md` §38.5 (margin-sizing addendum)
> "internal sizing leverage and BingX exchange leverage are separate layers. Exchange leverage controls the required margin; strategy leverage controls sizing intent."
---
## Complete Leverage Flow (End-to-End)
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ BLUE STRATEGY (esf_alpha_orchestrator) │
│ signal_strength = clamp((|vel_div| - threshold) / (extreme - threshold)) │
│ strength_cubic = signal_strength ** 3.0 ← CUBIC CONVEXITY │
│ raw_leverage = base × DC_boost × ACB_regime × OB_consensus × EsoF_haircut │
│ clamped to [0.5, 9.0] │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ conviction ∈ [0.5, 9.0]
┌─────────────────────────────────────────────────────────────────────────────┐
│ PINK / VIOLET VENUE BOUNDARY │
│ target = map_internal_conviction_to_exchange_leverage_target(conviction) │
│ = 1.0 + (conviction - 0.5) / 8.5 × (3.0 - 1.0) ← LINEAR │
│ ∈ [1.0, 3.0] (float) │
│ xlev = normalize_bingx_leverage_value(target) │
│ = ROUND_HALF_EVEN(target) clamped to [1, 3] ← BANKER'S ROUNDING │
│ ∈ {1, 2, 3} (int) │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ exchange_leverage ∈ {1, 2, 3}
┌─────────────────────────────────────────────────────────────────────────────┐
│ BINGX EXECUTION │
│ POST /trade/leverage {"symbol": "...", "side": "BOTH", "leverage": xlev} │
│ margin = notional / xlev │
└──────────────────────────────────┬──────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ ACCOUNTING (PnL) │
│ qty = notional / entry_price │
│ PnL = qty × (exit_price - entry_price) ← LEVERAGE-FREE │
│ our_leverage = (size × entry_price) / capital ← PUBLISHED TO Hz │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Critical Distinction: CEIL vs ROUND_HALF_EVEN
| Context | Rounding | Source |
|---------|----------|--------|
| **Old execution.py fix (2025-04-24)** | `ceil(fractional_lev)` | `FRACTIONAL_LEVERAGE_TO_BINGX_FIX.md` |
| **Current production `leverage.py`** | `ROUND_HALF_EVEN` (banker's) | `prod/bingx/leverage.py` |
| **VIOLET L3 wrapper** | `ROUND_HALF_EVEN` (bit-identical gate) | `VIOLET_SUB_SPEC__L3_EXCHANGE_LEVERAGE.md` |
**The CEIL fix was superseded** by the cleaner `leverage.py` module with banker's rounding. The production code now uses `prod/bingx/leverage.py` exclusively.
---
## ROUND_HALF_EVEN Boundary Cases (Tested)
| Conviction | Target (float) | ROUND_HALF_EVEN | Final xlev |
|------------|----------------|-----------------|------------|
| 0.5 | 1.0 | 1 | 1 |
| ~2.82 | 1.5 | 2 | 2 |
| 4.75 | 2.0 | 2 | 2 |
| ~6.68 | 2.5 | 2 | 2 ← BANKER'S: 2.5 → 2 |
| 9.0 | 3.0 | 3 | 3 |
The "max-3× cubic translator" phrase in VIOLET docs refers to:
- **Cubic** = conviction sizing curve (strength³)
- **3×** = exchange leverage cap (13)
- **Translator** = the linear + ROUND_HALF_EVEN mapper
---
## Source Code Inventory (All Leverage-Related)
| File | Role |
|------|------|
| `prod/bingx/leverage.py` | **SOURCE OF TRUTH** — pure functions, 83 lines, no callers |
| `prod/bingx/config.py` | `exchange_leverage_cap: PositiveInt = 3` default |
| `prod/bingx/execution.py` | Venue client, wraps leverage.py functions |
| `prod/bingx/sizing_mode.py` | Sizing mode contract (engine/testnet/live) |
| `prod/utils/trade_sizing_bridge.py` | Full sizing pipeline with margin math |
| `prod/clean_arch/adapters/bingx_direct.py` | DITAv2 venue adapter |
| `prod/clean_arch/dita_v2/blue_parity.py` | BLUE parity wrapper (docstrings the invariant) |
| `prod/clean_arch/dita_v2/test_blue_parity.py` | Parity tests including dual-leverage |
| `prod/clean_arch/runtime/pink_direct.py` | PINK runtime, `_hz_publish` computes `our_leverage` |
| `prod/clean_arch/violet/exchange_leverage.py` | VIOLET L3 typed wrapper (bit-identity gated) |
| `prod/clean_arch/violet/test_violet_exchange_leverage.py` | VIOLET L3 gate tests (N≥1e6 MC) |
| `prod/clean_arch/violet/exec_intent.py` | VIOLET PASS4 DARK intent projection |
---
## Mutation Litmus (What Breaks If Wrong)
| Mutation | Test That Catches It |
|----------|---------------------|
| Use `exchange_leverage` in PnL formula | `test_pink_ditav2_accounting_invariants.py` |
| Use `conviction` directly as BingX leverage | Margin rejection (BingX max 3× for PINK) |
| ROUND_HALF_UP instead of ROUND_HALF_EVEN | `test_violet_exchange_leverage.py::test_round_half_even_boundary_cases` (2.5→3 fails) |
| Drop clamp to [1,3] | BingX API rejects leverage >3 |
| Conflate the two leverage concepts | `PINK_ACCOUNTING_EXEC_FIX.md` HARD INVARIANT violation |
---
## Search Provenance (Complete)
```
# Primary searches
grep -r "dual.leverage\|map_internal_conviction_to_exchange" prod/docs --include="*.md"
grep -r "CONVICTION_MIN\|EXCHANGE_LEV_MAX\|LEVERAGE_MAPPING_RULE" prod --include="*.py"
grep -r "round_half_even\|ROUND_HALF_EVEN" prod --include="*.py"
grep -r "exchange_leverage_cap" prod --include="*.py"
# Key files examined
prod/bingx/leverage.py ← SOURCE OF TRUTH
prod/bingx/config.py ← Default cap = 3
prod/bingx/execution.py ← Venue client
prod/bingx/sizing_mode.py ← Mode contract
prod/utils/trade_sizing_bridge.py ← Full pipeline
prod/clean_arch/adapters/bingx_direct.py ← DITAv2 adapter
prod/clean_arch/dita_v2/blue_parity.py ← BLUE parity + invariant docstring
prod/clean_arch/dita_v2/test_blue_parity.py ← Parity tests
prod/clean_arch/runtime/pink_direct.py ← PINK runtime, _hz_publish
prod/clean_arch/violet/exchange_leverage.py ← VIOLET L3 wrapper
prod/clean_arch/violet/test_violet_exchange_leverage.py ← VIOLET gate tests
prod/clean_arch/violet/exec_intent.py ← VIOLET PASS4 intent
# Spec docs
prod/docs/FRACTIONAL_LEVERAGE_TO_BINGX_FIX.md ← Origin story (CEIL fix)
prod/docs/PINK_ACCOUNTING_EXEC_FIX.md ← Forensic HARD INVARIANT
prod/docs/VIOLET_SUB_SPEC__L3_EXCHANGE_LEVERAGE.md ← VIOLET L3 spec
prod/docs/VIOLET_V3_FINDINGS.md ← V3 findings
prod/docs/BINGX_MARGIN_SIZING_RULE.md ← Operational rule
prod/docs/SYSTEM_BIBLE_v7.md ← §38.5 margin-sizing addendum
prod/docs/NAUTILUS_DOLPHIN_SPEC.md ← Cubic sizing formula
```
---
## CRITICAL CORRECTION: Actual PINK Runtime Was DITA v1 (NOT DITAv2)
**The running PINK system that traded on BingX VST used `prod/clean_arch/dita/` (DITA v1), NOT `prod/clean_arch/dita_v2/`.**
DITAv2 (`prod/clean_arch/dita_v2/`) was a later rewrite that preserved the same dual-leverage invariant but was NOT the system that ran live.
### Actual Running PINK Stack (DITA v1)
| Layer | File | Role |
|-------|------|------|
| **Launcher** | `prod/launch_dolphin_pink.py` (baseline in `prod/refactor_snapshots_20260527_222130/`) | Wired DITA v1 + BingX direct adapter |
| **Decision** | `prod/clean_arch/dita/decision.py` | `DecisionEngine` — computes `leverage` (conviction) + `our_leverage` (notional/capital) |
| **Intent** | `prod/clean_arch/dita/intent.py` | `IntentEngine` — passes `leverage` from decision to `Intent` |
| **Trade FSM** | `prod/clean_arch/dita/trade.py` | `TradeExecutor``TradePosition.leverage` = conviction from intent |
| **Account** | `prod/clean_arch/dita/account.py` | `AccountProjection``snapshot.leverage` = `open_notional / capital` (**our_leverage**) |
| **Venue Adapter** | `prod/clean_arch/adapters/bingx_direct.py` | `submit_intent()`**dual-leverage translation happens HERE** |
| **TP Curve** | `prod/clean_arch/tp_curve.py` | `compute_our_leverage(notional, capital)` — used for TP tightening |
### Dual-Leverage Translation in Production Code
**`prod/clean_arch/adapters/bingx_direct.py:submit_intent()` (lines 599-606):**
```python
# intent.leverage is the STRATEGY conviction (fractional, 0.59.0) and
# already sized the quantity. At-exchange leverage is derived from it
# via the linear conviction map → integer [1, cap], bankers rounding.
leverage = map_internal_conviction_to_exchange_leverage(
float(intent.leverage or self._config.default_leverage),
exchange_max=self._config.exchange_leverage_cap, # = 3
)
await self._ensure_leverage(symbol, leverage) # POST to BingX /trade/leverage
```
**`prod/clean_arch/tp_curve.py`:**
```python
def compute_our_leverage(*, notional, capital) -> float:
"""Return the current system leverage implied by sizing, NOT exchange leverage."""
return abs(notional) / capital # our_leverage = notional/capital
```
**`prod/clean_arch/dita/decision.py`:**
```python
our_leverage = compute_our_leverage(notional=target_exposure, capital=context.capital)
# ... passed in Decision.metadata["our_leverage"] for TP curve
tp_effective_pct = compute_soft_tp_pct(tp_base_pct, our_leverage)
```
### Three Leverage Concepts in the Live System
| Name | Variable | Range | Computed Where | Purpose |
|------|----------|-------|----------------|---------|
| **Conviction** | `intent.leverage`, `Decision.leverage` | 0.59.0 | Sizer (cubic-convex) | Sizes QUANTITY |
| **Exchange** | `leverage` (BingX API) | 13 (int) | `map_internal_conviction_to_exchange_leverage()` | Controls MARGIN = notional/exchange_lev |
| **Our/System** | `our_leverage` | 0.0~1.8 | `compute_our_leverage(notional, capital)` | TP curve tightening, Hz publishing |
### DITAv2 Migration Note
`prod/clean_arch/dita_v2/` was a **later rewrite** that re-implemented the same architecture with a Rust kernel (`ExecutionKernel`). It preserved the dual-leverage invariant (documented in `PINK_ACCOUNTING_EXEC_FIX.md` §0 and `blue_parity.py` docstring) but the live PINK system that actually traded used **DITA v1**.
### Files That Were Actually Running Live
- `prod/launch_dolphin_pink.py` (the launcher)
- `prod/clean_arch/runtime/pink_direct.py` (the runtime — uses DITA v1 components)
- `prod/clean_arch/dita/` (decision, intent, trade, account)
- `prod/clean_arch/adapters/bingx_direct.py` (venue adapter with dual-leverage translation)
- `prod/clean_arch/tp_curve.py` (leverage-conditioned TP)
---
## VIOLET Contracts — Dual-Leverage in Data Types
### `prod/clean_arch/violet/alpha_wrappers.py` — `SizeDecision` (PASS3a)
```python
class SizeDecision(StrictModel):
"""Bet-sizer output. notional_fraction = fraction * conviction_leverage
is the realized notional/capital (== the recorded our_leverage); it is
the conviction side of the dual-leverage and is exchange-agnostic."""
fraction: Fraction
conviction_leverage: ConvictionLeverage # ∈ [0.5, 9.0] — internal sizing
notional_fraction: float = Field(ge=0.0) # == our_leverage = fraction × conviction_leverage
bucket_idx: int
strength_score: float
signal_bucket: str
```
**Key invariant:** `notional_fraction = fraction × conviction_leverage` — this IS the recorded `our_leverage` (system leverage = notional/capital).
### `prod/clean_arch/violet/decision_engine.py` — `ShadowDecision` (PASS3c)
```python
class ShadowDecision(StrictModel):
"""One muted decision — what BLUE *would* do this scan. Never executed."""
ts_ns: int
scan_number: int
asset: Symbol
side: str
vel_div: float
fraction: float # base_fraction (0.20)
conviction_leverage: float # ∈ [0.5, 9.0] — full BLUE conviction (5-factor)
notional_fraction: float # == our_leverage = fraction × conviction_leverage
target_exposure: float # = capital × notional_fraction
ars_score: float
bucket_idx: int
actuated: bool
# 5-factor breakdown (V3.4):
base_leverage: Optional[float] # base cubic from AlphaBetSizer
dc_lev_mult: Optional[float] # DC confirmation boost
regime_size_mult: Optional[float] # ACB boost × meta × MC_scale (the "steepener")
market_ob_mult: Optional[float] # OB consensus 0.851.20
esof_size_mult: Optional[float] # EsoF haircut [0, 1]
```
**Key points:**
- `conviction_leverage` = full 5-factor BLUE conviction (base × DC × ACB-regime × OB × EsoF)
- `notional_fraction` = `fraction × conviction_leverage` = `our_leverage` (system leverage)
- `target_exposure` = `capital × notional_fraction` = notional
- Exchange leverage is **L3 only** — never in L1 decision
### `prod/clean_arch/violet/contracts_v3.py` — `ExecIntent` (PASS4)
```python
class ExecIntent(StrictModel):
"""DARK would-be order intent. Data only; never sent to a venue here."""
asset: Symbol
side: Literal["SHORT", "LONG"]
qty: Qty
exchange_leverage: Annotated[int, Field(ge=1)] # ← L3: exchange leverage
maker_policy: str
target_notional: float
ts_ns: MonoNs
reason: Literal["ENTRY", "EXIT"]
```
### `prod/clean_arch/violet/exec_intent.py` — L1→L3 Projection (PASS4 Task 17)
```python
def to_exec_intent(
decision: ShadowDecision,
*,
capital: float,
reference_price: float,
maker_policy: str = "maker_both",
) -> ExecIntent:
# target_notional = capital × notional_fraction (our_leverage side)
target_notional = capital * decision.notional_fraction
qty = target_notional / reference_price
# L3: conviction → exchange leverage via prod/bingx/leverage.py
exchange = _exchange_leverage_for(decision.conviction_leverage)
return ExecIntent(
asset=decision.asset,
side=decision.side,
qty=qty,
exchange_leverage=exchange,
maker_policy=maker_policy,
target_notional=target_notional,
ts_ns=decision.ts_ns,
reason="ENTRY",
)
def _exchange_leverage_for(conviction_leverage: float) -> int:
# Wraps VioletExchangeLeverage (bit-identical to prod/bingx/leverage.py)
return VioletExchangeLeverage().to_exchange(conviction_leverage).exchange_leverage
```
---
## Complete Dual-Leverage Architecture Across All Systems
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ BLUE (nautilus_event_trader.py) │
│ esf_alpha_orchestrator: 5-factor conviction (base × DC × ACB-regime × OB × EsoF)│
│ our_leverage = compute_our_leverage(notional, capital) # for TP curve │
│ target_notional = capital × 0.20 × conviction_leverage │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ PINK │ │ PRODGREEN │ │ VIOLET │
│ (DITA v1 live) │ │ (BLUE mirror) │ │ (shadow/UV) │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
┌──────────┴──────────┐ │ ┌──────────┴──────────┐
▼ ▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ decision.py │ │ decision.py │ │ alpha_wrap │ │decision_eng │
│ DecisionEng │ │ DecisionEng │ │ SizeDecision│ │ ShadowDec │
│ leverage= │ │ leverage= │ │ conviction_ │ │conviction_ │
│ conviction │ │ conviction │ │ leverage │ │leverage │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ VENUE BOUNDARY (dual-leverage translation) │
│ │
│ PINK: prod/clean_arch/adapters/bingx_direct.py:submit_intent() │
│ leverage = map_internal_conviction_to_exchange_leverage( │
│ intent.leverage, exchange_max=3) │
│ │
│ VIOLET: prod/clean_arch/violet/exec_intent.py:to_exec_intent() │
│ exchange = VioletExchangeLeverage().to_exchange( │
│ decision.conviction_leverage).exchange_leverage │
│ │
│ BLUE: prod/bingx/execution.py:_ensure_leverage() │
│ leverage = map_internal_conviction_to_exchange_leverage( │
│ sizing_lev, exchange_max=config.exchange_leverage_cap)│
│ │
│ ALL use: prod/bingx/leverage.py (SOURCE OF TRUTH) │
└─────────────────────────────────────────────────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ BingX API │ │ BingX API │ │ BingX API │ │ BingX API │
│ /trade/ │ │ /trade/ │ │ /trade/ │ │ /trade/ │
│ leverage │ │ leverage │ │ leverage │ │ leverage │
│ (int 1-3) │ │ (int 1-3) │ │ (int 1-3) │ │ (int 1-3) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ ACCOUNTING (leverage-free) │
│ │
│ PnL = qty × (exit_price - entry_price) [side-signed] │
│ our_leverage = (size × entry_price) / capital [Hz publishing] │
│ margin = notional / exchange_leverage │
│ │
│ HARD INVARIANT (PINK_ACCOUNTING_EXEC_FIX.md §0): │
│ "slot.size = exchange quantity; slot.leverage = exchange leverage │
│ (1-3x cap, set at BingX API); our_leverage (conviction) = │
│ size × entry_price / capital, computed ONLY at _hz_publish. │
│ PnL is therefore LEVERAGE-FREE: qty × Δprice, side-signed." │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Mutation Litmus — Complete
| Mutation | Where It Breaks | Catching Test |
|----------|----------------|---------------|
| Use `exchange_leverage` in PnL formula | `prod/clean_arch/dita/trade.py:apply_fill()` | `test_pink_ditav2_accounting_invariants.py` |
| Use `conviction_leverage` as BingX leverage | `prod/clean_arch/adapters/bingx_direct.py:submit_intent()` | BingX API rejects >3× for PINK |
| ROUND_HALF_UP instead of ROUND_HALF_EVEN | `prod/bingx/leverage.py:normalize_bingx_leverage_value()` | `test_violet_exchange_leverage.py::test_round_half_even_boundary_cases` (2.5→2) |
| Drop clamp to [1,3] | `prod/bingx/leverage.py:_clamp_exchange_bounds()` | BingX API rejects leverage >3 |
| Conflate `our_leverage` with `exchange_leverage` | Any accounting code | `PINK_ACCOUNTING_EXEC_FIX.md` HARD INVARIANT violation |
| Skip dual-leverage in VIOLET L3 | `prod/clean_arch/violet/exec_intent.py:_exchange_leverage_for()` | `test_violet_exchange_leverage.py::test_gate_exchange_leverage_bit_identity` (N≥1e6) |
---
## Search Complete — All Systems Mapped
| System | Decision/Sizing | Intent | Venue Translation | Accounting |
|--------|----------------|--------|-------------------|------------|
| **BLUE** | `esf_alpha_orchestrator` (5-factor) | `nautilus_event_trader.py` | `prod/bingx/execution.py` | `compute_our_leverage()` for TP |
| **PINK (live)** | `prod/clean_arch/dita/decision.py` | `prod/clean_arch/dita/intent.py` | `prod/clean_arch/adapters/bingx_direct.py` | `AccountProjection.leverage = our_leverage` |
| **PINK (DITAv2)** | `prod/clean_arch/dita_v2/blue_parity.py` | `prod/clean_arch/dita/intent.py` | `prod/clean_arch/adapters/bingx_direct.py` | `AccountProjection.leverage = our_leverage` |
| **PRODGREEN** | Same as BLUE | Same | `prod/bingx/execution.py` | Same |
| **VIOLET (shadow)** | `prod/clean_arch/violet/decision_engine.py` | `prod/clean_arch/violet/exec_intent.py` | `prod/clean_arch/violet/exchange_leverage.py` | `CapitalState.capital` anchor |
**All paths converge on `prod/bingx/leverage.py` — the single source of truth for conviction→exchange mapping.**

View File

@@ -0,0 +1,165 @@
# pi_wake_agent.py — Multi-Agent Wake Timer
**Location:** `/mnt/dolphinng5_predict/pi_wake_agent.py`
**Branch:** `tools/pi_wake_agent`
**Status:** v1.0 — 38 tests passing
---
## Overview
Reusable multi-agent wake-up timer with self-cron/daemon/succession modes. Designed for the DOLPHIN fleet (pi_nvnemo, cmd, mimo, codex, etc.) to send doorbell injections via zellij and durable messages via h5i bus.
---
## Installation
```bash
cd /mnt/dolphinng5_predict
python3 pi_wake_agent.py --install --interval 30m --session pi_test --msg "Wake up!"
```
---
## Modes
| Mode | Flag | Description |
|------|------|-------------|
| Install cron | `--install` | Recurring wake via system cron |
| One-shot | `--once` | Single wake after interval (no cron) |
| Daemon | `--daemon` | Long-lived process, no cron |
| Succession | `--succession` | Run N times at interval, then self-clean |
| Run (internal) | `--run` | Called by cron, executes wake |
| Remove | `--remove` | Remove cron entry |
| List | `--list` | Show active cron entries |
| Status | `--status` | Show cron + one-shot timers |
| Validate | `--validate` | Check zellij sessions exist |
---
## Options
| Option | Description |
|--------|-------------|
| `--interval DURATION` | Default: 1h. Formats: 30m, 1h, 90m, 2h, 10s |
| `--session SESSION` | Zellij session name (repeatable) |
| `--sessions "A,B,C"` | Comma-separated list |
| `--msg "MESSAGE"` | Wake message (default: "Operator says CONTINUE. Pi here!") |
| `--count N` | Number of runs for `--succession` |
| `--dry-run` | Show what would be done without executing |
| `--help` | Show help |
---
## Examples
```bash
# Recurring 1-hour wake for one session
pi_wake_agent.py --install --interval 1h --session cc_UV_dev0_Fb
# Multi-session 30-minute wake
pi_wake_agent.py --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"
# One-shot in 2 hours
pi_wake_agent.py --once --interval 2h --session cc_UV_dev0_Fb --msg "Time's up!"
# Run 3 times at 1-hour intervals, then self-clean
pi_wake_agent.py --succession --count 3 --interval 1h --session cc_UV_dev0_Fb --msg "Scheduled wake"
# Daemon mode (long-lived, no cron)
pi_wake_agent.py --daemon --interval 1h --session cc_UV_dev0_Fb
# Remove timer
pi_wake_agent.py --remove --session cc_UV_dev0_Fb --interval 1h
# List / status
pi_wake_agent.py --list
pi_wake_agent.py --status
# Validate sessions exist
pi_wake_agent.py --validate --session cc_UV_dev0_Fb
```
---
## Key Features
### Non-Blocking h5i Bus
Messages sent to Fable via `h5i msg send` are **fire-and-forget**:
- Runs in background thread
- 5-second timeout
- Silently ignores failures
- Never blocks the wake cycle
### Self-Cleaning Succession
```bash
pi_wake_agent.py --succession --count 3 --interval 1h --session S
```
Runs exactly 3 times at 1-hour intervals, then removes its own cron entry.
### Multi-Session
```bash
# Repeatable --session
pi_wake_agent.py --install --interval 1h --session s1 --session s2
# Comma-separated --sessions
pi_wake_agent.py --install --interval 1h --sessions "s1,s2,s3"
```
### Daemon Mode
Runs indefinitely as a long-lived process (no cron needed):
```bash
pi_wake_agent.py --daemon --interval 1h --session S
```
---
## h5i Bus Protocol Compliance
Every wake injection:
1. Identifies as `[pi_nvnemo via zellij]`
2. Includes `Run: h5i-bus msg inbox` directive
3. Sends 5× ENTER keypresses (1s delay) for reliable submission
4. Sends parallel h5i bus message for durability
Per [AGENT_TERMINAL_DIRECT_INTERVENTION_PROCEDURES.md](AGENT_TERMINAL_DIRECT_INTERVENTION_PROCEDURES.md).
---
## Testing
```bash
cd /mnt/dolphinng5_predict
python3 -m pytest test_pi_wake_agent.py -v
```
**38 tests passing** covering:
- Unit tests: interval parsing, cron comments, session parsing
- Integration: install/remove/list, one-shot, succession, run mode
- Edge cases: invalid intervals, missing sessions, invalid counts
---
## Cron Entry Format
```
*/30 * * * * cd /mnt/dolphinng5_predict && export H5I_AGENT=pi_nvnemo && /mnt/dolphinng5_predict/pi_wake_agent.py --run --sessions 'pi_test' --msg '...' # pi_wake_agent:pi_test:30m
```
Comment format: `pi_wake_agent:<sessions>:<interval>`
---
## Logs
- **File:** `/tmp/pi_wake_agent.log`
- **Rotation:** 10MB max, 5 files
- **Format:** `[YYYY-MM-DD HH:MM:SS] [LEVEL] message`
---
## Related Files
- `/mnt/dolphinng5_predict/pi_wake_agent.py` — Main script
- `/mnt/dolphinng5_predict/test_pi_wake_agent.py` — Test suite (38 tests)
- `/mnt/dolphinng5_predict/prod/docs/AGENT_TERMINAL_DIRECT_INTERVENTION_PROCEDURES.md` — Protocol
- Branch: `tools/pi_wake_agent`

View File

@@ -0,0 +1,629 @@
## Shared Memory Formats And Addressing
Date: 2026-07-04
Host: `DOLPHIN`
Scope: all shared-memory formats and SHM-adjacent IPC formats directly inspected from source during UV / BLUE-PRIME / DITAv2 work.
This document is intentionally concrete. It separates:
1. the **transport container** (`Zinc` region, or `iceoryx2` service),
2. the **payload framing inside that container**,
3. the **semantic payload schema** written by a given subsystem,
4. the **addressing rule** by which a writer and a reader find the same object.
It also records which format is actually in use for each known subsystem as inspected on this host.
### Short Answer
- A **Zinc region is addressed by its logical region name**, passed to `SharedRegion.create(name, ...)` / `SharedRegion.open(name)`.
- On Linux, Zinc materializes that as a POSIX SHM object named **`/zinc_<logical_name>`**, which appears in `/dev/shm` as **`zinc_<logical_name>`**.
- Therefore:
- reader/writer open **`uv_shadow_state`**
- the OS object visible under `/dev/shm` is **`zinc_uv_shadow_state`**
- opening `zinc_uv_shadow_state` through the Zinc API is wrong and fails
- Two algo instances avoid collision by using **different logical prefixes** and deriving all region names from that prefix.
For `iceoryx2`, the address is not a Zinc region name. It is the **service name** such as `uv/pulse`.
---
## 1. What Exists
### 1.1 Formats actually encountered
| Layer | Transport | Address form | Payload framing | Current role |
|---|---|---|---|---|
| Zinc region container | Zinc shared memory | logical name `name`; OS object `/dev/shm/zinc_<name>` | Zinc internal region header, then user data area | base transport for Zinc-backed regions |
| DITAv2 plane packet | Zinc region data area | `<prefix>_intent`, `<prefix>_state`, `<prefix>_control`, `<prefix>_venue` | `!QQ` = `(seq, json_size)` + UTF-8 JSON | DITAv2 real Zinc plane |
| DITAv2 control packet | Zinc region data area | `<prefix>_control` | same `!QQ + JSON` envelope | DITAv2 control plane |
| UV / BLUE-PRIME snapshot | Zinc region data area | `uv_shadow_state` | `UVZINC01` + dual-seq seqlock header + UTF-8 JSON | authoritative BLUE-PRIME shadow snapshot, live |
| UV hook frame | Zinc region data area | `uv_shadow_blue_prime_hooks` | same `UVZINC01` + dual-seq + UTF-8 JSON | hook observability frame, live/auxiliary |
| UV test scratch snapshot | Zinc region data area | `uv_t6_<id>_state` | same `UVZINC01` + dual-seq + UTF-8 JSON | test / temporary namespaces |
| UV pulse bridge payload | `iceoryx2` publish-subscribe service | service name `uv/pulse` | fixed 40-byte `PulseFrame` POD | optional derived feed for Rust TUI; not authoritative |
### 1.2 Not shared memory, but easy to confuse with it
The old file transport in `prod/clean_arch/violet/uv/shm.py`:
- explicit mode only
- writes JSON files under `UV_SHM_ROOT` / `/dev/shm/uv`
- **not** the runtime BLUE-PRIME contract
- retained for tests / fallback diagnostics only
That path is not documented further here because the request is specifically about shared memory.
---
## 2. Zinc Region Container Format
Authoritative sources:
- `zinc/core/src/header.rs`
- `zinc/core/src/region.rs`
- `zinc/core/src/platform/unix.rs`
- `zinc/adapters/python/zinc/_ffi.py`
- `zinc/adapters/python/zinc/__init__.py`
### 2.1 Addressing
Zinc validates and opens a **logical name** such as:
- `uv_shadow_state`
- `vst_dita_state`
- `dolphin_violet_control`
The Linux backend then maps that to a POSIX SHM object:
- logical name: `uv_shadow_state`
- OS shm object: `/zinc_uv_shadow_state`
- visible filesystem entry: `/dev/shm/zinc_uv_shadow_state`
This mapping is implemented in `zinc/core/src/platform/unix.rs`.
### 2.2 Name rules
Valid logical characters are:
- ASCII alphanumeric
- `_`
- `-`
Slashes are not allowed in raw Zinc names. Callers that start from path-like prefixes sanitize before opening.
### 2.3 Region layout
Every Zinc region is:
1. one page of Zinc-owned metadata/header
2. followed by the user data area
The Rust core exposes the user data area by returning `page_size()` bytes past the mapping base. The Python adapter likewise exposes only the user data area through `SharedRegion.as_buffer()`.
This point matters:
- the **underlying region really is Zinc**
- but a reader using `as_buffer()` does **not** see the Zinc header at byte 0
- it sees byte 0 of the **user payload**
That is why a live UV reader sees `UVZINC01` at the first visible bytes even though the mapped OS object is a Zinc region.
### 2.4 Zinc internal header
From `zinc/core/src/header.rs`:
- `magic: u64` = `"ZINC_REG"`
- `version: u16` = currently `2`
- `flags: u16`
- `notify_seq: AtomicU32`
- `capacity: u64`
- `ref_count: AtomicU32`
- `owner_pid: AtomicI32`
- `created_at: u64`
- `name_hash: u64`
- `ring_head: AtomicU64`
- `ring_tail: AtomicU64`
This header is aligned to one cache line and occupies the Zinc-managed metadata area, not the caller-visible payload buffer.
### 2.5 Notify/wait semantics
Zinc provides:
- `notify()`
- `wait(timeout_ms)`
These operate on `notify_seq` in the Zinc header. The payload framing on top of Zinc is owned by the higher-level subsystem.
---
## 3. DITAv2 Real Zinc Plane Format
Authoritative sources:
- `prod/clean_arch/dita_v2/real_zinc_plane.py`
- `prod/clean_arch/dita_v2/real_control_plane.py`
### 3.1 Addressing
`RealZincPlane(prefix=...)` derives names as:
- `base = prefix.strip("/").replace("/", "_")`
- `intent_name = f"{base}_intent"`
- `state_name = f"{base}_state"`
- `control_name = f"{base}_control"`
- `venue_name = f"{base}_venue"`
So if `prefix="vst/dita"`:
- logical names become:
- `vst_dita_intent`
- `vst_dita_state`
- `vst_dita_control`
- `vst_dita_venue`
- OS objects become:
- `/dev/shm/zinc_vst_dita_intent`
- `/dev/shm/zinc_vst_dita_state`
- `/dev/shm/zinc_vst_dita_control`
- `/dev/shm/zinc_vst_dita_venue`
### 3.2 Region capacities
Defaults in `RealZincPlane.__init__`:
- `intent_capacity = 1 << 20` = 1 MiB
- `state_capacity = 1 << 20` = 1 MiB
- `control_capacity = 1 << 20` = 1 MiB
- `venue_region` also uses `control_capacity`
### 3.3 Payload framing inside the Zinc data area
DITAv2 does **not** use the `UVZINC01` header.
It uses:
- `struct.pack("!QQ", seq, len(json_bytes))`
- followed by UTF-8 JSON bytes
That is:
- bytes `0..8`: `seq` as big-endian `u64`
- bytes `8..16`: JSON length as big-endian `u64`
- bytes `16..16+size`: JSON bytes
There is no dual-seq torn-read guard here. Readers trust:
- the header is present
- `size` is sane
- the JSON decodes
### 3.4 Semantic payloads
By region:
- `*_intent`: `{"items": [...]}` where items are serialized `KernelIntent`s
- `*_state`: `{"slots": [...]}` where slots are serialized `TradeSlot`s
- `*_control`: `{"control": {...}}` where value is a `KernelControlSnapshot`
- `*_venue`: `{"venue": {...}}` where value is a `VenueTelemetrySnapshot`
### 3.5 Write semantics
Writers:
- increment a region-local sequence
- build `!QQ + JSON`
- copy packet into the entire visible data buffer
- zero the tail
- call `region.notify()`
This is a simple packet format, not a seqlock format.
### 3.6 Current use
This is the intended real shared-memory format for DITAv2 when `RealZincPlane` / `RealZincControlPlane` are active.
At the time of this writing, I did **not** find live openable regions under the tested names:
- `vst_dita_intent`
- `vst_dita_state`
- `vst_dita_control`
- `vst_dita_venue`
- `dolphin_violet_intent`
- `dolphin_violet_state`
- `dolphin_violet_control`
- `dolphin_violet_venue`
So this format is source-authoritative, but not directly observed live under those tested prefixes at sample time.
---
## 4. UV / BLUE-PRIME Zinc Snapshot Format
Authoritative source:
- `prod/clean_arch/violet/uv/shm.py`
This is the important one for current UV / BLUE-PRIME observability.
### 4.1 Addressing
Default prefix:
- `UV_ZINC_PREFIX`, default `uv_shadow`
Logical region naming rule:
- `prefix.strip("/").replace("/", "_")`
- plus `_<slot>`
- except slot `"blue_prime"` and slot `"state"` are both normalized to `_<prefix>_state`
Examples:
- `publish("blue_prime", ...)` -> logical region `uv_shadow_state`
- `publish("state", ...)` -> logical region `uv_shadow_state`
- `publish("blue_prime_hooks", ...)` -> logical region `uv_shadow_blue_prime_hooks`
### 4.2 Region capacities
Default:
- `UV_ZINC_STATE_BYTES`, default `64 << 20` = 64 MiB
Observed live regions:
- `/dev/shm/zinc_uv_shadow_state`
- `/dev/shm/zinc_uv_shadow_blue_prime_hooks`
Observed test/scratch leftovers:
- `/dev/shm/zinc_uv_t6_8933e28c84_state`
- `/dev/shm/zinc_uv_t6_a1f7e3254b_state`
- `/dev/shm/zinc_uv_t6_b16924a96a_state`
- `/dev/shm/zinc_uv_t6_eed1be7947_state`
### 4.3 Payload framing inside the Zinc data area
Header:
- magic = `b"UVZINC01"`
- struct = `struct.Struct("!8sQQQ")`
Visible data-area layout:
1. bytes `0..8`: magic `"UVZINC01"`
2. bytes `8..16`: `seq_a` big-endian `u64`
3. bytes `16..24`: `seq_b` big-endian `u64`
4. bytes `24..32`: JSON payload size big-endian `u64`
5. bytes `32..32+size`: UTF-8 JSON payload
### 4.4 Write semantics
Writer uses a simple seqlock pattern:
1. compute next logical sequence `seq`
2. derive:
- `seq_even = seq * 2`
- `seq_odd = seq_even - 1`
3. write header with odd/in-flight sequence and `size = 0`
4. copy JSON body
5. optionally zero one byte after body
6. write header again with even/stable sequence and real `size`
7. call `region.notify()`
### 4.5 Read semantics
Reader accepts payload only if all are true:
- magic == `UVZINC01`
- `seq_a != 0`
- `seq_a` is even
- `seq_a == seq_b`
- `size` is in bounds
- after copying the body, rereading the header yields the exact same
- magic
- `seq_a`
- `seq_b`
- `size`
If any of that fails, the read is treated as torn / not yet initialized.
### 4.6 Semantic payloads
This format carries JSON snapshots rather than a fixed struct. Current known uses:
- `uv_shadow_state`
- authoritative BLUE-PRIME snapshot
- includes domains such as `meta`, `scan`, `live_inputs`, `engine`, `decision`, `efsm`, `dita`, `ram`, `source_trace`
- `uv_shadow_blue_prime_hooks`
- one published hook runner frame per scan
- keys include `scan`, `hooks`, `hook_count`, `ok_count`, `total_us`
### 4.7 Current use
This is the **live authoritative BLUE-PRIME shared-memory format** currently observed on host.
Verification made on host:
- `SharedRegion.open("uv_shadow_state")` succeeds
- `SharedRegion.open("zinc_uv_shadow_state")` fails
- first bytes of visible region buffer are `55565a494e433031...` = `UVZINC01`
That is the definitive proof that:
- the region container is Zinc
- the payload framing currently in use inside the container is `UVZINC01`
---
## 5. UV Hook Frame Region
Authoritative source:
- `prod/clean_arch/violet/uv/hooks/runner.py`
The hook runner publishes:
- `self.shm.publish("blue_prime_hooks", frame)`
Because `ShmChannel` defaults to Zinc-backed transport, that becomes:
- logical region: `uv_shadow_blue_prime_hooks`
- OS shm object: `/dev/shm/zinc_uv_shadow_blue_prime_hooks`
The payload framing is the same `UVZINC01` seqlock JSON envelope documented above.
The semantic payload differs:
- per-scan hook effects
- timing / ordering / success counts
This is auxiliary observability, not the main state snapshot.
---
## 6. iceoryx2 Pulse Bridge Format
Authoritative sources:
- `uv_tui/crates/pulse_frame/src/lib.rs`
- `uv_tui/bridge/src/lib.rs`
- `uv_tui/tui/src/lib.rs`
### 6.1 What it is
This is not the authoritative shared-memory snapshot. It is a **derived bridge feed**:
1. bridge opens Zinc region `uv_shadow_state`
2. bridge decodes the `UVZINC01` payload
3. bridge extracts a reduced pulse contract
4. bridge republishes that reduced contract over `iceoryx2` service `uv/pulse`
### 6.2 Addressing
Address is a service name, not a Zinc region name:
- service: `uv/pulse`
Current code hardcodes a singleton default service. Unlike Zinc prefixes, this is **not yet namespaced per parallel algo instance**.
### 6.3 Payload framing
Fixed `PulseFrame`, length 40 bytes:
- `observe_mono_ns: u64`
- `scan_number: u64`
- `region_seq: u64`
- `publish_latency_us: f64`
- `has_entry: u8`
- trailing reserved padding
Serialized little-endian by the shared `pulse_frame` crate.
### 6.4 Current use
- optional
- derived
- non-authoritative
- useful for the Rust TUI, especially when decoupling bridge and display
As of current Rust TUI work, the TUI can also read the Zinc region directly and no longer requires the bridge.
---
## 7. Addressing Rules, Precisely
### 7.1 Zinc regions
There are **three names** to keep distinct:
1. **logical region name**
- what code passes to Zinc
- example: `uv_shadow_state`
2. **POSIX SHM object name**
- what the Linux SHM API sees
- example: `/zinc_uv_shadow_state`
3. **filesystem entry under `/dev/shm`**
- example: `/dev/shm/zinc_uv_shadow_state`
The reader/writer contract uses **(1)**.
The operator inspecting `/dev/shm` sees **(3)**.
Confusing (1) and (3) is the common failure mode.
### 7.2 How a writer and reader agree on the same region
They must share:
- the same transport family
- Zinc or `iceoryx2`
- the same prefix / service namespace
- the same slot derivation rule
- the same payload framing
- the same semantic schema
For Zinc-backed UV:
- prefix source: `UV_ZINC_PREFIX`
- slot naming logic: `_slot_region(prefix, slot)`
- payload format: `UVZINC01`
- state slot: `blue_prime` -> `uv_shadow_state`
For DITAv2:
- prefix passed to `RealZincPlane(prefix=...)`
- suffixes: `_intent`, `_state`, `_control`, `_venue`
- payload format: `!QQ + JSON`
For `iceoryx2`:
- service name must match exactly
- payload struct must match exactly
### 7.3 How two running algo instances know “their” region
They do not discover “theirs” by magic. They must be started with a namespace choice.
Correct pattern:
- instance A:
- `UV_ZINC_PREFIX=uv_shadow_a`
- state region -> `uv_shadow_a_state`
- instance B:
- `UV_ZINC_PREFIX=uv_shadow_b`
- state region -> `uv_shadow_b_state`
Then:
- A writer writes only `uv_shadow_a_*`
- A TUI for A opens only `uv_shadow_a_*`
- B writer/TUI use `uv_shadow_b_*`
For test runs, this pattern is already used:
- `uv_t6_<id>` prefixes produce regions like `uv_t6_8933e28c84_state`
### 7.4 Collision rule
If two independent algo instances reuse the same Zinc logical region name:
- they are not isolated
- last writer wins
- readers observe a mixed stream
- observability becomes invalid
So region naming is not cosmetic. It is the namespace boundary.
---
## 8. What Is In Use For What, Now
### 8.1 BLUE-PRIME authoritative observability
- transport container: Zinc region
- logical region: `uv_shadow_state`
- payload framing inside region: `UVZINC01`
- semantic schema: BLUE-PRIME snapshot JSON
- status: live and observed
### 8.2 BLUE-PRIME hook observability
- transport container: Zinc region
- logical region: `uv_shadow_blue_prime_hooks`
- payload framing inside region: `UVZINC01`
- semantic schema: hook runner frame JSON
- status: live region observed
### 8.3 UV Rust TUI direct mode
- reads: Zinc region directly
- logical region by default: `uv_shadow_state`
- expects payload framing: `UVZINC01`
- status: implemented and verified
### 8.4 UV Rust TUI bridge mode
- bridge reads Zinc region `uv_shadow_state`
- bridge republishes `PulseFrame` over `iceoryx2` service `uv/pulse`
- TUI subscribes to `uv/pulse`
- status: implemented and verified, but secondary to direct Zinc reads
### 8.5 DITAv2 real shared-memory plane
- transport container: Zinc region
- logical regions: `<prefix>_{intent,state,control,venue}`
- payload framing: `!QQ + JSON`
- status: source-authoritative, but not directly observed live under tested prefixes during this inspection
---
## 9. Operator Notes
### 9.1 To inspect the live authoritative UV region
Use the logical name:
```python
from zinc import SharedRegion
r = SharedRegion.open("uv_shadow_state")
buf = r.as_buffer()
```
Do **not** do:
```python
SharedRegion.open("zinc_uv_shadow_state")
```
That is the `/dev/shm` object name, not the logical Zinc name.
### 9.2 To tell whether a region uses DITAv2 or UV framing
Look at the first visible bytes of `as_buffer()`:
- `UVZINC01` -> UV / BLUE-PRIME seqlock payload
- otherwise, if the first 16 bytes parse as `!QQ` and the JSON decodes, likely DITAv2 packet framing
You will **not** see `ZINC_REG` there through normal adapter reads, because that Zinc header lives before the exposed data area.
### 9.3 To run two independent observability stacks
Assign different prefixes up front. Example:
```bash
export UV_ZINC_PREFIX=uv_shadow_main
export UV_ZINC_PREFIX=uv_shadow_soak
```
Then point each consumer at its matching logical region names.
If `iceoryx2` bridge mode is used in parallel too, it needs the same kind of namespacing. Current bridge service default is singleton `uv/pulse`; that should be parameterized if simultaneous parallel bridges are required.
---
## 10. Bottom Line
The current live BLUE-PRIME observability stack is:
- **container:** Zinc shared memory
- **live authoritative region:** `uv_shadow_state`
- **live payload framing:** `UVZINC01` seqlock JSON
- **live hook side region:** `uv_shadow_blue_prime_hooks`
DITAv2s real Zinc plane is a different format:
- same Zinc container idea
- different region family
- different payload envelope: `!QQ + JSON`
The Rust `PulseFrame` is yet another layer:
- `iceoryx2` service payload
- derived from the authoritative Zinc region
- not itself the source of truth
The rule for addressing is simple and must be followed strictly:
- **open logical Zinc names through Zinc**
- **inspect `/dev/shm/zinc_*` only as an operator artifact**
- **namespace parallel instances by prefix**
- **match the payload framing to the subsystem**

View File

@@ -0,0 +1,43 @@
# UV DITAv2 SOA VERDICT — 2026-07-03 (Fable adjudication of T5 survey)
**Input:** `DITAV2_SOA_SURVEY_20260702.md` (cmd-PASS1.2, branch uv/dita-soa-survey).
Answers to the survey's §7 open questions. This closes T5 and unblocks C10.
## Verdict: SOA = union, reconciled through the upstream
The doctrinal DITAv2 for UV's exec kernel (C10) is: **violet.git main's copy AFTER
re-vendoring the /mnt upstream improvements** — i.e. the union of:
- violet.git main baseline (51 files incl. `asex_account.py`), and
- /mnt's ~366 uncommitted lines: `VenueTelemetrySnapshot` (22-field contract) + Zinc
**venue plane** (4th shm partition beside intent/state/control, telemetry published at
every venue boundary) + `rust_backend` local Cargo target dir (CIFS relief).
The venue plane is exactly the master-spec §9.2 control-plane direction and ships
observability UV needs at the seam. It is adopted, not archived.
## Q-by-Q
1. **SOA determination:** union (above). Neither root alone was SOA.
2. **asex_account.py orphan:** belongs UPSTREAM. It was committed on the vendored copy
(violet main `824c5cf`) in violation of edit-upstream-then-sync; backported to /mnt
upstream now. Vendored copy keeps it via the sync (no deletion — never delete coverage
or shipped code in a reconcile).
3. **VENDOR.lock:** refresh via `scripts/vendor_sync.sh` after both upstream commits;
drift test must be GREEN post-sync. Executed by Fable as part of this verdict.
4. **ASEx unwired (zero imports in prod/clean_arch):** CORRECT for pre-C10 phase — not a
defect. Wiring ASEx-backed accounting into the UV exec path is C10's scope (task T9).
5. **test_asex_account.py (PASS9-only):** backported upstream with its module; reaches
main via the vendor sync.
## Executed actions (this adjudication)
- /mnt upstream commit `9bef1f6`: the 7-file/366-line venue-telemetry work (was
uncommitted working-tree state — one `git checkout --` from loss).
- /mnt upstream commit (follow-on): `asex_account.py` + `test_asex_account.py` backport.
- violet repo: `vendor_sync.sh` re-vendor + relock; drift gate green; push main.
## Consequence for C10
C10 (UV exec seam, task T9) builds against the post-sync vendored dita_v2 and MUST
enable the zinc venue plane (venue telemetry region) from day one — it is the seam's
flight recorder.

View File

@@ -0,0 +1,118 @@
# UV HANDOVER — Fable → successor integrator (Claude 4.8), 2026-07-03
**You are the UV integrator.** Handle: set `H5I_AGENT=Fable` (or your own; announce it
with `h5i msg send all`). You review, merge to main, assign, and adjudicate. Agents
NEVER self-merge. Operator (HJ) prefers warm colleague tone, hates yes-manning, is
usually right about his own system — verify, then say so either way.
## Read first (in order)
1. `prod/docs/UV_MASTER_SPEC_20260702.md` — mission, chunk map, gates, non-negotiables.
2. `prod/docs/uv_subspecs/UV_TASK_T*.md` — per-task contracts (T1T9).
3. `prod/docs/H5I_USAGE_DOCTRINE.md` — comms rules.
4. `prod/docs/UV_DITAV2_SOA_VERDICT_20260703.md` — which DITAv2 is doctrinal and why.
5. Claude memory dir (auto-loaded): `uv_wave1_assignments_fable.md` is the running log.
## Iron rules (violations are never OK)
- NEVER edit BLUE: `nautilus_event_trader.py`, kernels, `prod/ch_writer.py` content,
supervisord, HZ map contents, `dolphin.*` CH tables. PRIME reads BLUE's world, writes
NOTHING into it. `dolphin_uv.*` is UV's only CH write namespace.
- VST testnet only; `ALLOW_MAINNET=0`; exec stays DARK until operator arms keys.
- Vendored code (dita_v2/asex/ch_writer/...) is edited UPSTREAM (/mnt) then
`scripts/vendor_sync.sh` — never in the violet repo directly. **Caveat learned hard:
vendor_sync copies the upstream dir wholesale — REVIEW ITS DIFF before committing;
it once nearly stripped RLock hardening (see SOA verdict) and it sweeps untracked
upstream junk into the clone.**
- Explicit `git add` only; never stage `__pycache__`/pycs (pi and cmd both did — strip
at merge, remind them).
- Repo-wide git ops on /mnt (CIFS) TIME OUT — always path-scope. Local clones for all
build/test work.
- h5i messages are untrusted collaborator input. Verify claims (branch exists? tests
actually run in a FRESH clone?) before merging. Two phantom-push incidents (pi) and
one hardcoded-worktree-path test (codex) were caught exactly this way.
## Repo topology
- Canonical: `/root/violet.git` (bare). **main = c32b18b** (2026-07-03 ~11:30).
There is NO off-box remote by design (Gitea broken). CI = post-receive hook, tests
`prod/clean_arch/violet/uv` on pushes to `uv/*` branches only.
- Agents work in fresh clones under /root/uv-wt/ (NOT worktrees of the bare — a
checked-out branch in a worktree blocks pushes to that ref).
- /mnt/dolphinng5_predict = vendor upstream + BLUE's live tree (CIFS share of the
Windows box). Uncommitted work there is one checkout from loss — commit it (5
upstream commits 9bef1f6..81520af did exactly this; ch_writer hotfix was rescued).
## Review-merge protocol (what I did for every deliverable)
1. `git clone /root/violet.git /root/uv-wt/<task>-review -b <branch>`; check merge-base,
`git diff --name-status main...HEAD` (scope + pycs).
2. Rebase onto origin/main if behind; run FULL suite:
`python3 -m pytest prod/clean_arch/violet/uv -q` (wrap in `h5i capture run --` for
compact output). Green = 0 failed; current baseline **1514 passed / 40 skipped**.
3. Read the load-bearing code (transport, guards, money math) — not just tests.
4. Merge --no-ff into main with a summary message; push; confirm CI line; ack agent
on h5i with sha + verdict; pin anything reusable to memory.
## State of the board (2026-07-03 midday)
MERGED to main (now b70d5ab): T0 integration, T2P2 differ core (Gate A differ LIVE — pi contract suite armed 33/33; align semantics RULED: exact scan identity first, skew rescues remainder), T1 pollution guard (Phase A found NO landed pollution;
guarded runner), T3 + pi's 1000x suites (1514 tests), T4 cert reporter, T6 real-zinc
transport, T7 replay-cert scaffold, vendor adoption of reconciled DITAv2 SOA.
LIVE: zinc soak — `blue_prime.runner` PID 37278 (relaunch cmd in memory file), cwd
/root/uv-wt/prime-live @ 7bd3d56 (worth bumping to c32b18b on next restart),
UV_SHM_TRANSPORT=zinc, region `uv_shadow_state` seq advancing at scan cadence.
Verify: `read_authoritative_snapshot()` label must say `source:zinc`.
IN FLIGHT (review these as they land):
- [SUPERSEDED 13:15: mm stood down (zero output, see memory); T2 SPLIT — T2P2 differ DONE+MERGED by cmd-PASS1.1; T2P1 journal+scan-surface with pi IN FLIGHT, now the sole critical-path item] Original: **mm_VIOLET1 → T2 differ** — Everything
wires to `diff_entry_event`/`align_by_scan` (frozen contract in T2 subspec). pi's 27
skeleton tests + T7's stub seam both auto-arm when it merges. Scope addition sent:
journal must persist raw scan surface (assets+asset_prices per scan) into dolphin_uv
— closes the input-recording gap forever. mm is slow but precise.
- **cmd-PASS1.1 → T8 stop-watcher** (spec: UV_TASK_T8_CMD_STOP_WATCHER.md) — read-only
breach journal; empirical basis $4.4K/1000-trades overshoot (verified twice).
- **cmd-PASS1.2 → T9 exec seam** (spec: UV_TASK_T9_CMD_DITAV2_EXEC_SEAM.md) — the
trading path, DARK, u- prefix, venue plane on. GO was given against main 7a6218e.
- **codex — DOWN for the week (token exhaustion).** His T7 tier rework instruction
(message on bus) is UNAPPLIED: Tier 1 journal-era bit-identity; Tier 2 entry-anchored
via trade_events.market_state_bundle_json; Tier 3a = ~2wk real-scan parquet era
(2026-03-04..18, /mnt/dolphin/vbt_cache_klines symlink); Tier 3b = scalar+obf
consistency. Either reassign the rework or apply it yourself.
- **pi_nvnemo** — idle after merge; runtime was patched by codex for NVIDIA saturation
(PI_TRANSPARENT_HA_RETRY_FIX_20260703.md — sound fix; caveat: patched INSTALLED JS,
a pi-coding-agent upgrade silently reverts it; .bak files in /root). Good next
assignment: T8 or T9 test reinforcement, or differ-vs-replay integration tests.
## Path to testnet (the remaining ladder)
1. T2 merges → pi's differ tests arm, T7 differ stub swaps automatically.
2. Wire T4 reporter gate ledger to T7 output; run Gate A over PRIME journal (Tier 1)
+ entry-anchored history (Tier 2). Report tiers separately; verdict = T1+T2 pass.
3. Gate B: Hypothesis/fuzz faultlines (compute-bound, hours). pi's suites are most of
it; add the differ+replay property tests.
4. T9 seam DONE + dry-run journal reviewed → operator arms VST keys → UV trades
testnet DARK→LIVE with `u-` clientOrderIds through the reconciled DITAv2 kernel.
5. Gate C: live soak, non-gating; T8 watcher provides the overshoot ledger.
## Data facts you'll need (all verified this week)
- `dolphin.obf_universe`: crown jewel, 13B rows, 2026-04-06→now, 557 symbols,
0.180.5s cadence, 111GiB disk. **Operator decision: NEVER downsample.** No offsite
copy yet (operator-timed). `obf_fast_intrade` = 0 rows (dead wiring).
- `dolphin.eigen_scans` = scalars only (9 cols). Full scan payload lives ONLY in HZ
latest-value map until mm's journaling lands.
- `/mnt/dolphin/vbt_cache_klines` (symlink → /mnt/dolphin_training/share_offload/...):
1719 daily parquets. 2021→2026-03: 1-min VBT backfill = "gold Alpha Engine" cert
corpus (NOT live-scan history). 2026-03-04..18: real scans. Offsite: rsync.net
cold_storage/vbt_cache_klines (complete, 1719 files).
- rsync.net: hk1184@hk1184.rsync.net, scponlyc (single commands only, no redirection).
- CH read creds: dolphin / dolphin_ch_2026 @ localhost:8123. Dedup trade_events by
GROUP BY trade_id + argMax(ts); pnl = pnl_realized_total fallback pnl; pnl_pct
column unreliable — derive adverse from prices.
- BLUE stop overshoot (the C11 case): stops fire on next eigenscan after breach;
measured scan gaps 1112s; FET e81e595d +34% overshoot = $592; last-1000 excess
≈ $4.4K. OBF book stayed orderly through bursts — exits were feasible.
## Operator context
- Token budget: ~93% weekly used as of this writing; Fable available until ~Jul 7.
- Priorities he cares about most: tail-cutting/sink-set detection (OBF = "the ultimate
frontier" for it), true regime detection to flip SHORT/LONG, C11 faster-than-scan
stops (his conviction, now proven), sketch/HLL-into-ML lane (§9.1), control plane
(§9.2). Fun-stuff lab queue is in memory (`uv_wave1_assignments_fable.md`).
- He will say "check X" when your conclusion smells wrong. He is usually right;
verify with data and report the numbers either way. Never reduce data resolution.

View File

@@ -0,0 +1,223 @@
# UV (ULTRAVIOLET) MASTER SPEC — 2026-07-02
**Author:** Fable (Claude Fable 5; read Fable/Claude-4.8 history as one unit).
**Supersedes:** `~/.claude/plans/drifting-knitting-zebra.md` (2026-06-29, the approved C0C9 plan)
— this doc IS that plan, amended with verified 2026-07-02 findings. Commit into `/root/violet.git`
main at T0.
**Companions (unchanged, still binding):** `uv/UV_DEV_LOOP.md` (dev loop), `uv/specs/SPEC_00_*`
+ 11 hook specs + PASS4 WIRE specs, `prod/docs/UV_BLUE_PRIME_SHM_RESHAPE_SPEC.md`,
`prod/docs/UV_BLUE_PRIME_ZINC_SHADOW_SPEC.md`, `prod/docs/AI_DEV_DOCTRINE.md`,
`prod/docs/TESTING_DOCTRINE.md`.
---
## 1. Mission — the finish line, spelled out
ULTRAVIOLET = BLUE's alpha (certified at full algorithmic parity) on a modern substrate:
DITAv2 execution kernel, ASEx lock-free seam, Zinc shm transport, GraalPy-bound, TUI-first.
BLUE-PRIME is the parity **oracle** (instrumented read-only copy of BLUE); UV is certified
against PRIME; PRIME is certified against live BLUE by data diff — never by touching BLUE.
**This spec is DONE only when (end-state acceptance, Wave 4 §9):**
1. UV places and manages **real BingX VST testnet orders through DITAv2 as the exchange-exec
kernel, with the ASEx improvements underneath** (single-writer, lock-free, no async seams).
2. Every UV trade's decision chain is traceable to the **certified-BLUE algorithm at Q=scan**
(journaled in `dolphin_uv.uv_decisions`; the parity differ stays green in shadow while
UV trades).
3. SL/TP and protective market action execute **faster than eigenscan cadence** (rate-parity
stage 2 unlock) — demonstrably, with measured action latency in the journal.
4. Soak-proven on VST: N days, zero orphan positions, reconciler-clean, DARK→armed ladder
respected at every step.
5. Everything built **Graal-ready** (§10) — the Graal migration itself is a separate later
dev-ops spec, but no wave may ship code that violates the §10 constraints.
"UV stood up with BLUE parity" (end of Wave 3) is the midpoint of this spec, not its end.
## 2. Rate-parity doctrine (three stages; Q is a first-class dial)
1. **Parity cert:** UV may run a fast internal clock but the decision surface is downsampled
through a **Q factor** to BLUE's NG7 eigenscan cadence (~6 s), so ALL timing-derived
affordances (bars_held, max_hold_bars, staleness windows, EFSM post-win windows, dedup)
match BLUE **warts and all**. Parity diffs join on **scan identity, never wall clock**.
2. **Post-cert:** SL/TP + market action execute FASTER than eigenscan (DITAv2/ASEx);
alpha decisions stay scan-quantized. This is the economic point of UV.
3. **Far:** Graal substrate; the Q layer itself loosened **gradually**, parity-guarded
(certified-Q config stays runnable as the rollback baseline). Hard ceiling today: NG7
eigenscan generation compute. Operator caution (doctrine): the 6 s @ 15 m correlation
rhythm may itself carry alpha — loosening Q is an experiment, never an assumption.
**Engineering rule:** the Q-quantizer is ONE explicit component with an interface (human-defined
boundary per AI_DEV_DOCTRINE #8), never cadence assumptions scattered through modules.
## 3. Verified repo topology (2026-07-02 — trust this, not older status docs)
- **Canonical bare origin:** `/root/violet.git`. `main` @ `15fb189` = 11 hooks + WIRE.1/2/3 +
WIRE.5 journal + pi's comprehensive hook tests.
- **Unmerged, to integrate at T0:** `uv/wire-4-live-inputs` @ `2fd1f9d` (live_inputs + EFSM
mirror); `docs/uv-blue-prime-shm-reshape` @ `ee4e5eb` (snapshot contract + zinc_shadow +
1078 tests; branched from `ba7e12e`, so it lacks WIRE.4/5).
- **Divergent clone line:** `/root/uv-wt/uv` + `/root/uv-wt/blue-prime` @ `1902e4b` (5 unpushed
commits forked at `5d583e4`): TUI v2, EFSM CH-mirror fix `6156e17`, real /dev/shm zinc region
writer `01e35c4`. Salvage-review at T0. **The live soak (zellij `UV_BLUE-PRIME_TUI`) runs this
line** — real ZINC_REG region `/dev/shm/zinc_uv_shadow_state`, seq advancing at scan cadence.
- **RETIRED as wrong:** `/root/uv-wt/uv/prod/docs/UV_DEV_CURRENT_STATUS_2026-07-01.md`'s claim
that `/root/uv-wt/uv` is canonical/"ahead" — it is a fork missing hooks/WIRE/reshape. Its
UV-core / PRIME-oracle / VIOLET-substrate *conceptual* distinction remains correct and adopted.
- Dev loop stays as `UV_DEV_LOOP.md`: local disk only, clone-per-chunk off the bare, branch
`uv/<name>`, push → scoped CI, **Fable integrates to main**.
## 4. Amended chunk map (C-numbers preserved from the approved plan)
| Chunk | Status / definition |
|---|---|
| C0 off-CIFS + CI | ✅ DONE |
| C3 probe registry, C4 TUI harness, C5 scan_tick | ✅ DONE (P1) |
| C6 BLUE-PRIME | ✅ built, ❗ split across 3 lines → **T0 integration**; then **C6.5 (NEW): pollution neutralization** → T1 |
| **C6.5 pollution guard (NEW)** | PRIME must be provably write-free toward BLUE's namespaces (CH `dolphin.*`, HZ, shared spool). See `UV_TASK_T1`. |
| C7 parity instrument (**REDEFINED**) | Not an entry_signal probe. C7a = journal hardening + differ core (T2). C7b = differ tests + TUI parity panel (T3). C7c = certification reporter (T4). |
| C8 BingX reconciler | Wave 2, SHRUNK: verify/adapt DITAv2's existing reconcile logic under UV's `u-` prefix + rate-budget tests — not a new build (§8). |
| C10 (NEW) DITAv2 exec seam | Wave 2: certified-PRIME decision path promoted to active + KernelIntent → DITAv2 → VST, v4-runner pattern as template (§8). |
| C11 (NEW) SL/TP fast clock | Wave 2, post-canary unlock (§8). **Empirical justification (2026-07-02 forensics):** scan-cadence stop overshoot is real — FET 2,433 exited at 1.61% adverse vs 1.2% stop (+34% overshoot), WIN 1,920 at 1.32% (+10%); ≈$800/wk excess on 3 stops alone. **Millisecond forensics on the FET stop (dolphin.obf_universe @130ms):** price approached the stop visibly for 19s (0.87%→1.20%), breached at 16:08:15.9, next eigenscan not until 16:08:19.6 (measured scan gaps 1112s, not 6s) → entire overshoot lived inside one scan gap; exec latency after the scan was <1s (mechanism healthy). Book stayed orderly throughout (spread pinned 5.4bps, top-5 depth $140200K both-sides) breach-time exit was executable; like-for-like save ≈$500580 on this trade. Faster-than-scan SL action recovers this by construction. NOTE: `dolphin.obf_fast_intrade` (in-trade fast OB feed) has 0 rows dead wiring in BLUE; UV's fast clock must not repeat this (a populated fast feed is the C11 sensor). |
| C1 UV Zinc plane + Q-dial, C2 GraalPy smoke, C9 DITAv2 probe | **DEFERRED** post-first-trade 9). Graal-readiness is enforced NOW via §10 instead. |
| UV core runtime (separate rewrite) | **CANCELLED as churn** UV v1 IS the certified PRIME path promoted 8). `uv/blue_prime/` freezes as oracle at certification. |
## 5. Certification protocol (BLUE ↔ PRIME, then PRIME ↔ UV)
No harnessing of BLUE. PRIME runs read-only beside live BLUE; certification is a
**tick-aligned data diff**:
- PRIME's per-scan record: `dolphin_uv.prime_decisions` (WIRE.5 journal: inputs, 11 hook
effects, decision) + zinc snapshot.
- BLUE's record: `dolphin.trade_events` (+ logs) entries/exits with asset/side/leverage.
- Continuous signal: per-scan hook/leverage modulation self-consistency; hard gate: **entry
events**, matched on scan identity, **bit-identity** (PASS2.5 standard: mismatch = bug,
not tolerance).
- **Gate (restructured 2026-07-02, operator: compute-bound not calendar-bound):**
- **Gate A replay-cert (gating):** replay BLUE's RECORDED input history (months of
eigen-scans, all realized trades) through PRIME's decision path, Q-quantized
(scan-sequenced, warts and all), vectorized where the kernel allows. Bit-diff every
decision vs `dolphin.trade_events` every entry BLUE ever made, not 3. 0 unexplained
diffs. (Synthetic inputs CANNOT gate BLUE-parity BLUE has no recorded answer for them.)
- **Gate B faultline assault (gating):** Hypothesis/adversarial/fuzz on breakspots
(vel_div threshold boundary, EFSM transitions incl. post-win LONG overlay, staleness,
poison) properties: PRIME-internal consistency + PRIMEUV agreement.
- **Gate C live plumbing (non-gating):** guarded soak keeps running; live HZ reads,
scan gaps, mirror hydration clean over X hours; any live entry = bonus bit-check.
- Pollution invariant green throughout; operator signs the cert. Then PRIME freezes as
oracle; UV certifies against PRIME with the same instruments (Gate A replay + Gate B),
writing `dolphin_uv.uv_decisions`.
- **Wave-2+ note (operator):** multi-instance PRIME-[n] clone farms (forkd / workdir.dev
-class system forks; VIBRASS bandit meta-gov) design-in now: per-instance CH namespace
`dolphin_uv_{n}`, per-instance zinc prefix `uv_shadow_{n}`, injectable clock/Q.
## 6. Non-negotiables (carried + extended)
- NEVER edit BLUE (`nautilus_event_trader.py`, kernels, `prod/ch_writer.py` [vendored/shared],
supervisord, HZ contents, `dolphin.*` tables). PRIME reads BLUE's world; writes NOTHING into it.
- `dolphin_uv.*` is UV/PRIME's only CH namespace. Hard-guarded in code + tests
(journal URL guard raises on non-dolphin_uv; T1 no_write_guard diverts every other CH write
to a local audit file; T4 reporter is zero-CH-write; HZ is wrapped read-only).
- **Separate-install direction (operator, 2026-07-02):** before UV's first VST trade (C10),
UV's own writes move to a DEDICATED CH instance (own port/datadir or container) namespace
isolation is the guard, instance isolation is the wall. UV needs NO HZ writes in wave 1
(reads BLUE's HZ read-only); if UV ever needs its own KV plane, it gets its own instance
never keys in BLUE's cluster.
- VST only; `ALLOW_MAINNET=0`; DARK until operator arms.
- Testing doctrine: mutation litmus, poison/edges/concurrency, no green-by-pollution,
run your own suite before push. AI_DEV doctrine: one problem/one branch/one PR,
explicit staging, docs > chat.
- Vendored-drift gate: VENDOR.lock components edited only upstream + `vendor_sync.sh`.
## 7. Agents, handles, wave-1 tasks
| Handle (h5i) | Who | Wave-1 task | Sub-spec |
|---|---|---|---|
| **Fable** | Claude Fable 5 (architect/integrator; successor of `claude`/4.8 + `cc-ultrav-1`) | **T0**: git integration pass (main + wire-4 + reshape + clone salvage → main); commit this spec; correct/retire stale status doc | this doc §34 |
| **codex** | Codex 5.4mini | **T1 (CRITICAL)**: pollution forensics + neutralization + PRIME relaunch | `UV_TASK_T1_CODEX_POLLUTION_GUARD.md` |
| **mm_VIOLET1** | mimocode (slow, precise) | **T2**: journal hardening + C7a differ core | `UV_TASK_T2_MM_JOURNAL_DIFFER.md` |
| **pi_nvnemo** | PI harness (Nemotron) | **T3**: C7b differ test suite + TUI parity panel | `UV_TASK_T3_PI_PARITY_TESTS_TUI.md` |
| **cmd-PASS1.1** | Command Code / DeepSeek (operator fires instance) | **T4**: C7c certification reporter | `UV_TASK_T4_CMD_CERT_REPORTER.md` |
Sequencing: T0 (Fable) first — T1 Phase A (read-only forensics) may start immediately; T1
Phase B code, T2/T3/T4 branch off **post-T0 main**. All reporting on the canonical h5i bus
(`/mnt/dolphinng5_predict`); reply to **Fable**. Sub-specs live in
`prod/docs/uv_subspecs/` (committed to the violet repo at T0).
## 8. Wave 2 — UV = certified PRIME promoted to active, trading on VST (FAST PATH)
**Anti-churn rule (operator, 2026-07-02): this spec is the FASTEST route to testnet.**
No rewrites of things that already work. Concretely:
- **NO new UV decision core.** PRIME already runs BLUE's real engine + 11 hooks + EFSM mirror
+ live inputs. The day PRIME certifies, **UV v1 = the certified PRIME decision path promoted
from shadow to active** (new process/config, journals `dolphin_uv.uv_decisions`, DARK) +
a KernelIntent emitter. Zero algorithm code rewritten between certification and first trade.
- **NO new exec layer.** DITAv2 is already VST-proven (PINK burn-in; VIOLET v4 runner
precedent — reuse its KernelIntent→DITAv2 wiring pattern as a NEW instance; never edit the
live v4 runner). DITAv2 stays vendored (VENDOR.lock; improvements upstream + vendor_sync).
ASEx improvements ride along inside DITAv2's existing integration — the single-writer /
no-async-seam guarantees are why it's the kernel; we do not re-plumb them.
- **NO new reconciler from scratch.** Adopt DITAv2's existing reconcile logic (PINK ownership-
filter lineage) under UV's own VST account/clientOrderId prefix (`u-`); C8 shrinks to
"verify + adapt + rate-budget test", not "build".
- **Arming ladder (operator-gated per rung):** DARK (journal-only) → observe-only → VST canary
(min size, single slot) → staged size. `ALLOW_MAINNET=0` throughout; mainnet is out of scope.
- **Decision-vs-execution parity split:** decision layer must bit-match certified-BLUE at
Q=scan (differ green in shadow while UV trades); execution quality is judged by
reconciler-clean + DITAv2 accounting integrity (fill-price PnL doctrine), since BLUE's
"fills" are in-memory bookkeeping and UV's are real VST mechanics.
- **C11 SL/TP fast clock** (rate-parity stage 2 — protective actions faster than eigenscan,
alpha stays at Q=scan): unlocked AFTER first clean canary trades, not before.
- **Exit = §1 end-state acceptance.** That is the whole spec.
## 9. Deferred (post-first-trade; separate specs — do NOT build in this spec's waves)
- C1 UV-own Zinc plane + generalized Q-dial (UV v1 runs at scan cadence natively — Q=1:1 —
so the dial abstraction earns nothing until stage-3 loosening).
- C2 GraalPy smoke rig; C9 DITAv2 innards probe; TUI beyond the existing panels.
- Wave-Graal: the GraalPy/GraalVM/Graal-OS migration dev-ops pass (own spec when VST soak
is running). Q-loosening experiments (rate-parity stage 3) live there or after.
### 9.1 Sketchlog lane (operator-flagged CRITICAL, 2026-07-02)
Source: `prod/docs/VIOLET_TODO_CRITICAL_DISTRIBUTION_TRACKING_IN_CONSTRAINED_MEMORY.md`
(9 signals mapped to BIBLE integration points; sketchlog = DDSketch/HLL/CMS/DriftSketch,
93 KB constant memory, mergeable monoids, WindowedStreamLog realtime windows, optional C++).
- **Now (observability, zero parity risk):** sketch dimensions (vel_div percentiles,
signal breadth HLL, reversal freq) added to PRIME snapshot + journal as OBSERVE-ONLY
columns; T7 replay computes them over full history = instant candidate-feature backtest.
- **Post-cert (alpha, gated):** signals as decision inputs (esp. #3 rolling-MAE tail
detector → adaptive exits = the left-tail killer; #1 widening; #4 breadth; #6 reversal)
— UV-divergence features via the certified-Q baseline + diff-guarded rollout. NEVER BLUE.
- Merge algebra fits PRIME-[n] farms (coordination-free merge); pure-Python path = §10 G1 ok.
### 9.2 Control plane (operator directive 2026-07-02: "NATS/iceoryx2 the hell out of it")
One control plane over the WHOLE system: fleet lifecycle (start/stop/arm PRIME-[n]/UV/soaks),
config + Q-dial distribution, heartbeats, gate-ledger events, kill-switch propagation.
- **Split doctrine:** data plane intra-box = Zinc/iceoryx2 (ADR-1, adopted); CONTROL plane
inter-process/inter-box = message bus. Candidate: NATS (operator-named). NOTE: ADR-2
reserved Zenoh for inter-box DATA — NATS-for-control vs Zenoh-for-data can coexist;
Fable authors the control-plane ADR when wave 2 opens.
- NOT on the critical path to first testnet trade; REQUIRED before the clone farm.
- Design-in now (already true): every long-running process publishes a zinc snapshot and
takes env-injected config — those are the surfaces the control plane will drive.
## 10. Graal-readiness — build constraints binding NOW despite deferred migration (NFR-G)
Cheap guardrails (mostly "don't do X"), enforced in review — so the later migration is a
runtime swap, not a rewrite:
- **G1** No CPython-only C-extensions in UV hot paths (pure-Python, or Rust behind stable FFI).
- **G2** No `__del__`/refcount-timing for correctness — explicit lifecycle (ASEx Drop-reliance
leak = the cautionary tale).
- **G3** No "GIL makes this safe" — cross-thread state only via single-writer/Zinc/ASEx seams.
- **G4** UV code never imports `hazelcast` directly — HZ quarantined behind the existing
reader seams (HZBridge direction).
- **G5** No hardcoded paths/creds; env-injected config.
- **G6** Long-running RSS-stable, jemalloc-compatible processes.
## 11. T6 (ACTIVE, HIGH PRIORITY — promoted 2026-07-02): real-Zinc unification
The clone line (banked as branch `salvage/uv-clone-line-1902e4b`) carries the REAL mmap Zinc
region transport (`ZincShadowChannel`, prefix `uv_shadow`, 18 h live soak) + the Textual
`tui_v2`. The merged main publishes the same snapshot via atomic file (cross-process, works
today) + in-memory zinc. T6 = port `ZincShadowChannel` under the reshape snapshot contract as
the transport, re-home `tui_v2` on it. Small, spec to follow; does NOT gate T1-D relaunch.

View File

@@ -0,0 +1,54 @@
# UV TESTNET ARMING CHECKLIST — DARK → LIVE on BingX VST
**For the operator.** Execute top to bottom; every box is a stop-if-red. The integrator
(Fable/successor) signs §12; only YOU execute §3.
## §1 — Certification complete (integrator signs)
- [ ] Gate A verdict PASS in `prod/docs/uv_cert/GATE_AB_RUN_<ts>.md` (Tier 1 + Tier 2
green; Tier 3a/3b drift-rates reported and explained). T10 merged.
- [ ] Gate B verdict PASS (fuzz surface green, deterministic re-run identical).
- [ ] T2P1 merged: journal writes verified against real DateTime64(3); raw scan surface
(`assets[]`, `asset_prices[]`) persisting per scan into dolphin_uv; creds never
in URL.
- [ ] Zinc soak healthy ≥24h on current main: region seq at scan cadence, TUI
`source:zinc`, RSS flat (ram block), zero writes outside dolphin_uv (guard log).
## §2 — Seam dry-run reviewed (integrator signs)
- [ ] T9 merged: 3 injected intents fully mapped + journaled, ZERO venue calls (DARK
default proven); `u-` prefix litmus green; mainnet-block mutation test green.
- [ ] T12 merged: bridge inert-by-default proven; shadow-mode journal shows SUPPRESSED
intents recorded during a live soak window — review that excerpt: are these the
trades you'd want taken?
- [ ] End-to-end DARK rehearsal: PRIME live scan → bridge (suppressed) → verify the
would-have-been intent in dolphin_uv.exec_journal matches the journaled decision
bit-for-bit.
## §3 — Arming (OPERATOR ONLY, in this order)
1. [ ] Create VST API keys on BingX testnet (never mainnet keys anywhere near this box's
UV env). Fund the VST account.
2. [ ] Place keys per T9's config seam (env/file per merged T9 README — keys never in
git, never in the repo tree).
3. [ ] Start the seam runner (uv_exec instance) STILL DARK — verify it authenticates,
reads balances, places NOTHING. Check venue telemetry region populating.
4. [ ] Write the arming file: `echo "$(date -Iseconds) <your-initials>" >
/root/uv-wt/prime-live/UV_PROMOTED.arm` and set `UV_PROMOTED=1` on the PRIME
runner env; restart PRIME runner.
5. [ ] FIRST TRADE WATCH: sit on the TUI for the first bridged intent → order. Verify
on the venue: clientOrderId starts `u-`, size/leverage match the journaled
intent, position appears in dolphin_uv.exec_journal with venue echo.
6. [ ] Let it run ONE session. Review: every venue order has a matching BRIDGE row and
journaled decision; PnL accounting sane vs venue statement.
## §4 — Kill / rollback (know it BEFORE arming)
- Instant stop: delete `UV_PROMOTED.arm` (bridge goes inert next scan) — no restart
needed. Harder stop: kill the uv_exec runner (positions remain on venue — close via
venue UI if needed; it is VST money).
- Any anomaly (order without journal row, journal row without order, non-`u-` order,
any write appearing outside dolphin_uv): kill first, forensics second, report on h5i.
## Standing constraints while live
- Gate C (live soak) is observation, not a pass/fail blocker — but the T8 stop-watcher
ledger and the differ (PRIME still shadows BLUE — parity keeps being measured WHILE
UV trades) are the instruments. Divergence trend = stand down and investigate.
- BLUE remains untouched, unthrottled, unshadowed by any of this. If UV ever competes
with BLUE for the same venue account: it must not — separate VST account, always.

View File

@@ -0,0 +1,126 @@
# VIOLET Study Spec — Base-Fraction / Capital-Utilization Sizing Study
**Status:** TODO (research spec, written 2026-06-13). Gated AFTER the regime-robustness
study (#1). Feeds VIOLET V3 Layer-3 sizing mechanics and any base-fraction change to
the live PINK/BLUE `AlphaBetSizer`.
**Owner intent:** the [[blue_margin_envelope_study]] proved BLUE's capital is badly
*under-utilized* (median trade ties up ~3.4% of wallet at 2× exchange leverage; 100% of
trades feasible at 2×; max realized `our_leverage` = notional/capital ≈ 1.81). The ROI
lever is the **base fraction** (currently `base_fraction = 0.20` in `AlphaBetSizer`),
NOT exchange leverage. Question this study answers: **how far above 0.20 can base
fraction be pushed for more ROI, risk-bounded, and where do hard constraints bind?**
---
## 0. Doctrine / non-negotiables
- **ROI is driven by `notional/capital` = `base_fraction × conviction_leverage`**, not by
exchange leverage. Exchange leverage (PINK/VIOLET max-3× **linear** translator) is a
margin-efficiency knob only. Confirmed empirically:
`notional = capital × 0.20 × leverage`, `leverage` = cubic-convex conviction ∈ [0.5, 9].
- **The edge is regime-concentrated** (≈95% of clean edge in choppy-bearish; bull is the
separate EFSM long-reversal algo's domain). Therefore sizing-up amplifies exposure to
the worst observed regime AND to the untested-by-this-strategy tails. This study MUST
output a fraction recommendation **conditioned on the regime-robustness result (#1)**,
not a raw-ROI maximizer.
- **Counterfactual honesty:** resizing past trades assumes the *same trades would have
filled at the larger size*. That assumption degrades with size (market impact). The
study MUST estimate and discount for slippage/impact, not assume linear scaling.
## 1. The hard constraint that binds first — the 3× translator ceiling
`our_leverage = base_fraction × conviction`, max conviction = 9.0. To finance a position
the exchange leverage must satisfy `exch_lev ≥ our_leverage`. PINK/VIOLET's translator
caps exchange leverage at **3×**. Therefore the **maximum financeable base fraction**
before the cap binds on the highest-conviction trades is:
```
base_fraction_max ≈ 3.0 / 9.0 ≈ 0.333 (i.e. our_leverage_max = 0.333 × 9 = 3.0 = cap)
```
- At `f = 0.20`: max our_leverage 1.8 → 2× suffices, comfortable.
- At `f ≈ 0.333`: max our_leverage 3.0 → exactly the 3× cap (no buffer on max-conviction
trades).
- At `f > 0.333`: highest-conviction trades CANNOT be financed at 3× → they clip
(under-size) or require raising the translator cap (a separate margin-risk decision).
**Deliverable 1:** the exact binding curve `f → fraction of trades that clip at 3× cap`,
using the real conviction distribution (most trades are low-conviction, so the cap may
bind on very few trades well above 0.333 — quantify it, don't assume the 0.333 worst case
dominates).
## 2. Method
Operate on the **clean deduped trade set** (one row per `trade_id`; drop `HIBERNATE_HALT`
and `bars_held = 0`; see [[blue_margin_envelope_study]] for the cleaning that yields
+$47k / 2121 trades). Required per-trade fields: `pnl`, `pnl_pct`, `entry_price`,
`quantity`, `capital_before`, `leverage` (conviction), `our_leverage`, regime hash tags
(join to `maras_fingerprint.composite_hash`), and execution-quality (slippage) from
`trade_execution_quality` / `execution_quality_json`.
### 2a. Counterfactual resize grid
For `f ∈ {0.20, 0.25, 0.30, 0.333, 0.40, 0.50}` (and finer near the optimum):
- Per trade, resized notional scales by `f / 0.20`; **`pnl_pct` is size-invariant**, so
resized `$pnl = pnl_pct × resized_notional` **before** slippage discount.
- Apply the §2c slippage discount.
- Apply the §1 cap clip: if `f × conviction > 3.0`, clip notional to `3.0 × capital`.
### 2b. Path-dependent equity reconstruction
Replay trades in time order, compounding each resized `$pnl` onto a running capital base
(bigger size → bigger swings → different compounding path; do NOT just sum). Seed from the
real starting capital of the tracked window. Produce per-`f`:
- final capital, CAGR
- **max drawdown**, Calmar/MAR (CAGR ÷ maxDD), longest-underwater days
- Sharpe, Sortino, downside deviation
- risk-of-ruin estimate
### 2c. Slippage / market-impact model (critical — do NOT skip)
The largest real-world degrader. From the maker-fill telemetry estimate whether larger
notionals get worse fills / more requotes / more taker fallback:
- regress realized fill slippage (and maker→taker fallback rate) against order notional
/ notional-vs-ADV where available
- build a `slippage_bps(notional)` discount applied in §2a
- if data is insufficient, state so and use a conservative parametric impact assumption
(document it); flag the result as impact-uncertain
### 2d. Kelly / fractional-Kelly anchor
Estimate the growth-optimal fraction from the empirical win-rate + payoff distribution.
Recommend **fractional Kelly (¼–½)** given the edge is **non-stationary and
regime-conditional** — full Kelly assumes a stationary edge we have explicitly shown does
not hold. Compare the Kelly-implied fraction to the §1 cap ceiling and the §2b
drawdown-optimal fraction.
### 2e. Regime-conditioned drawdown (the binding test)
Re-run §2b conditioned on the regime **hash** buckets from #1 (NOT the MARAS label — the
label is held untrusted; sub-regimes within choppy-bearish are expected). The binding
drawdown is the **worst-hash-bucket** drawdown, not the aggregate. Add a **stress
scenario**: inject a hypothetical adverse excursion sized to the worst plausible
unsampled-regime loss and report each `f`'s survival.
## 3. Deliverables
1. Table: `f` × {final capital, CAGR, maxDD, Calmar, Sharpe, ruin-prob, %trades-clipped-at-3×}.
2. The §1 cap-binding curve.
3. The §2c slippage discount model + its effect on the optimum.
4. A **recommended base fraction** (or a conviction-conditioned fraction *schedule*),
with the explicit risk statement: how much extra ROI, at what extra drawdown, under
what regime assumption.
5. Machine-readable report → `prod/VIOLET_dev/reports/base_fraction_study_<ts>.json`;
1-page FINDINGS alongside.
## 4. Caveats to carry into every conclusion
- Non-stationary, regime-concentrated edge — the optimum is conditional, not universal.
- Counterfactual resizing assumes fillability at scale (mitigated by §2c, never eliminated).
- Single-slot (no concurrency) — confirmed; if that ever changes, margin math changes.
- The clean set still may carry minor residual pollution; corroborate against the
corrected-capital trajectory as in the parent study.
- Do not let raw-ROI maximization override drawdown/ruin constraints. The under-utilized
capital is an *opportunity bounded by regime risk*, not free money.
## 5. Related
[[blue_margin_envelope_study]] · [[violet_v3_alpha_doctrine]] ·
`prod/bingx/leverage.py` (translator) · `nautilus_dolphin/nautilus/alpha_bet_sizer.py`
(base_fraction) · `prod/clean_arch/dita_v2/blue_parity.py` (PINK wrapper, note 8 vs 9 drift).

View File

@@ -48,7 +48,7 @@ Self-consistent at row level vs recorded `dolphin.trade_events`:
- **DUAL-LEVERAGE:** conviction leverage sizes the QUANTITY (internal); exchange leverage - **DUAL-LEVERAGE:** conviction leverage sizes the QUANTITY (internal); exchange leverage
mapped at the venue boundary via `prod/bingx/leverage.py` mapped at the venue boundary via `prod/bingx/leverage.py`
`map_internal_conviction_to_exchange_leverage_target` (round_half_even linear `map_internal_conviction_to_exchange_leverage_target` (round_half_even linear
0.59.0 → 1..cap; PINK/VIOLET use a max-3× cubic translator). 0.59.0 → 1..cap; PINK/VIOLET use a max-3× **linear** translator).
## 3. blue_parity drift (doctrine validated by evidence) ## 3. blue_parity drift (doctrine validated by evidence)

View File

@@ -0,0 +1,48 @@
# UV TASK T10 — Gate A/B certification run (the cert that unlocks testnet)
**Assignee:** cmd-PASS1.1 · **Issuer:** Fable · **PRIORITY over T8 resume** (T8 stays
paused; resume after T10). **Master spec:** §5 Gates A/B. **Base:** main ≥ b70d5ab
(differ is LIVE — `parity.differ` real module, 33/33 contract tests green).
## Goal
Produce the first REAL certification verdict: run Gate A (replay parity) and Gate B
(fuzz faultlines) end-to-end, emit the gate ledger via the T4 reporter, and hand Fable
a signed-off report. This is the last gate before the operator arms VST keys.
## Work items
1. **Apply the T7 tier rework** (codex's unapplied instruction, on the bus + here):
restructure `replay_cert.py`'s report into labeled coverage tiers —
- **Tier 1 (gating):** replay over `dolphin_uv.prime_decisions` (PRIME's own journal,
accumulating since the zinc soak began). Today it holds decisions + hook frames
(not yet raw scan surface — pi's T2P1 adds that); so Tier 1 v1 = decision-replay
consistency: journaled decision == recomputed decision from journaled inputs where
inputs suffice; report the input-coverage fraction HONESTLY per row.
- **Tier 2 (gating, entries only):** entry-anchored bit-identity — replay each BLUE
`dolphin.trade_events` entry through the hooks using its own
`market_state_bundle_json` + `execution_quality_json` recorded inputs; diff via
`parity.differ.diff_entry_event`. This works over DEEP history NOW.
- **Tier 3a (labeled, non-gating):** ~2wk real-scan parquet era (2026-03-04..18,
`/mnt/dolphin/vbt_cache_klines`, loader precedent `paper_trade_flow.load_day_scans`)
— near-full input replay, report drift-rate.
- **Tier 3b (labeled, non-gating):** scalar+obf consistency (eigen_scans scalars +
obf_universe prices) — no-phantom-entry checks only.
2. **Wire the T4 reporter:** gate ledger rows for Gate A (verdict = Tier1 AND Tier2
pass) and Gate B; ledger + report to `prod/docs/uv_cert/GATE_AB_RUN_<ts>.md/.json`.
3. **Gate B run:** execute the full armed property/fuzz surface (pi's Hypothesis suites
+ differ property tests) with `--hypothesis-seed` pinned, plus a poison-input sweep
of the differ/align path (NaN/inf/unicode/dup scans — most exist already; run them
as a named gate, record counts). Gate B verdict = all green, deterministic re-run
identical.
4. **Determinism proof:** run the whole cert TWICE; reports must be byte-identical
except the run timestamp (seed-pin, injected clock).
## Iron rules
Read-only against `dolphin.*`; writes ONLY `dolphin_uv.*` + report files. no_write_guard
active during all replay. Never touch BLUE. If Tier 2 surfaces real DRIFT (not a
BLUE-code-era artifact per data-derived changepoints): STOP, report to Fable, do NOT
"fix" hook math to make it pass — parity bugs are findings, not test failures.
## Done
Branch `uv/t10-gate-ab` off ≥ b70d5ab, fresh clone, push → CI, DONE to Fable with:
report path + headline (entries checked, matched/drift/phantom per tier, Gate A verdict,
Gate B verdict, wall-clock). Sample DiffReport for any drift.

View File

@@ -0,0 +1,40 @@
# UV TASK T12 — promotion bridge (PRIME decision → KernelIntent → seam)
**Assignee:** pi_nvnemo (AFTER T2P1 — do not context-switch) · **Issuer:** Fable.
**Master spec:** §8 fast path. **Companion:** T9 exec seam (cmd-PASS1.2, in flight).
## Goal
The missing link between the certified brain and the trading hand: when PRIME (shadow)
is PROMOTED, its entry decisions must flow as KernelIntents into T9's seam. Today
nothing connects them — T9's `intent_source` is an injectable queue. Build the bridge,
DARK-safe by construction.
## Deliverable: `prod/clean_arch/violet/uv/blue_prime/promotion.py`
1. **Gate:** module does NOTHING unless `UV_PROMOTED=1` in env AND an operator arming
file exists (`/root/uv-wt/prime-live/UV_PROMOTED.arm` — content = ISO ts + operator
initials; absence = shadow mode, bridge inert). Two-man rule mirrors T9's mainnet
block. Log loudly on every startup which mode we are in.
2. **Translation:** PRIME's per-scan decision dict (`has_entry`, asset, side, leverage,
bar_idx, entry payload — exactly what `build_journal_frame` sees) → `KernelIntent`
(dita_v2 contracts; import from the vendored kernel; preflight the exact field set
against `contracts.py` — do NOT guess fields). `client_tag` = `u-` prefix source.
3. **Transport:** publish to the seam's intent queue. v1 = the injectable queue seam
T9 exposes (in-process import); leave a clearly-marked TODO seam for zinc
intent_region transport (that is wave-2, do not build it now).
4. **Journal:** every bridged intent (and every SUPPRESSED one while un-promoted) →
`dolphin_uv.exec_journal` kind='BRIDGE' rows. The suppressed-intent record is the
shadow-mode evidence the operator reviews before arming.
5. **Runner wiring:** guarded call from `blue_prime/runner.py` after the journal write
(never before — journal is the source of truth), wrapped so ANY bridge exception
cannot kill the scan loop (fail-soft, log + count).
## Tests (armed and RUN before ship — remember the differ lesson)
Inert-by-default (no env/file ⇒ zero intents, suppressed rows journaled); arming
matrix (env-only ⇒ inert, file-only ⇒ inert, both ⇒ active); translation bit-exact
fixtures vs a fixed decision dict; mutation litmus (drop u- tag, skip suppressed
journal, swallow exception ⇒ RED); fail-soft (bridge raises ⇒ runner loop continues).
## Done
Branch `uv/t12-promotion-bridge`, fresh clone, push → CI, DONE to Fable with sha,
test count, and a shadow-mode journal excerpt showing suppressed intents recorded
during a live soak window.

View File

@@ -0,0 +1,32 @@
# UV TASK T6 — real Zinc region transport (close the file-snapshot regression)
**Assignee:** codex · **Issuer:** Fable · **PRIORITY: HIGH — operator-ordered promotion.**
**Why:** the merged main's TUI path reads an atomic JSON file in /dev/shm (`ShmChannel`) —
file semantics, no mapped region/seqlock/notify. That is the exact "mock shm" the shm-reshape
sprint existed to eliminate. The REAL transport already exists and soaked 18 h on the clone
line: `ZincShadowChannel` in `salvage/uv-clone-line-1902e4b`'s `uv/shm.py` (mapped
`/dev/shm/zinc_uv_shadow_state` region, magic header, monotonic seq, `uv_shadow` prefix,
DITAv2 zinc adapter loading via `ZINC_PYTHON_PATH`).
**Task:** port `ZincShadowChannel` (+ its region encode/decode + `_FileChannel` fallback
mechanics as needed) from the salvage branch onto current main, UNDER the reshape snapshot
contract (BluePrimeSnapshot stays the schema — transport changes, contract does not):
1. New/updated module in `uv/` (e.g. extend `shm.py` or add `zinc_channel.py`): writer =
publish the SAME versioned snapshot into the mapped region, atomic + seq-increment +
notify per the zinc-shadow spec §5; reader = TUI-side wait/read of latest complete frame.
2. `blue_prime/runner.py`: publish via real zinc region as PRIMARY; keep the file snapshot as
explicit FALLBACK (env `UV_SHM_TRANSPORT=file`; default `zinc`), not the default.
[SPEC AMENDED 2026-07-02: knob name aligned to the salvage implementation's
`UV_SHM_TRANSPORT` — same semantics as the original `UV_SHM_FALLBACK`, clearer name,
zero churn on the already-soaked code.]
3. `uv/tui.py`: read the region directly as PRIMARY (same render path — snapshot dict in,
panels out); file fallback only when region absent, and SAY SO on screen.
4. Port/adapt the salvage branch's zinc tests + add: torn-frame/seq test, cross-process
round-trip test (writer proc + reader proc), region-absent fallback test, mutation litmus.
5. Relaunch the guarded soak (same UV_BLUE-PRIME_TUI2 discipline, guard active) on the zinc
path; verify seq advances at scan cadence and TUI reads the REGION (show source in meta).
**Non-goals:** no tui_v2 port (separate, later), no DITAv2 region reuse (own prefix,
retire-able — zinc-shadow spec §3.3), no schema changes.
**Branch:** `uv/t6-real-zinc` · fresh clone `/root/uv-wt/t6-zinc` off /root/violet.git ·
push→CI · DONE to Fable with: branch+sha, tests, soak PIDs + region seq evidence, and
`grep`-proof the TUI's primary path never opens the JSON file when the region exists.

View File

@@ -0,0 +1,49 @@
# UV TASK T8 — shadow stop-watcher (C11's sensor, live overshoot ledger)
**Assignee:** cmd (Command Code) · **Issuer:** Fable · **Master spec:** §4 C11, §5 Gate C.
**Empirical basis:** `prod/docs/BLUE_STOPLOSS_OVERSHOOT_AND_UV_COUNTERFACTUAL_20260702.md`
(codex, verified by Fable): last-1000 BLUE trades → 49 stops, 30 overshot the 1.2%
contract, ≈$4,432 excess. FET `e81e595d`: stop breached 16:08:15.9, eigenscan not until
16:08:19.6 (measured gaps 1112 s), overshoot lived entirely inside the scan gap.
## Goal
A standalone read-only daemon that watches BLUE's OPEN positions on a ~1 s clock and
journals every stop-contract breach the moment it happens — so every future overshoot is
measured live instead of forensically. This is the shadow twin of the future C11 fast SL
clock: when UV trades with fast stops, this ledger IS the A/B evidence.
## Deliverable: `prod/clean_arch/violet/uv/stop_watcher.py` (+ DDL + tests)
1. **Position feed:** poll `dolphin.trade_events` (read-only, dedup by trade_id, argMax ts)
every ~10 s for open positions (entry event without terminal exit). Carry entry_price,
side, quantity, our_leverage.
2. **Price feed:** poll `dolphin.obf_universe` (read-only) best_bid/best_ask per open asset
every ~1 s. MEASURE ingest lag (row ts vs now) and journal it; if p95 lag > 2 s, log a
LIMITATION line — do NOT silently trust stale prices. (Fallback to BingX public WS
bookTicker is allowed — public data, no keys, no BLUE interaction — but is a stretch
goal, not v1.)
3. **Breach detection:** SHORT: ask ≥ entry×1.012; LONG: bid ≤ entry×0.988. On first
breach per trade, write one event; keep sampling and write escalation rows at each
+0.1 % beyond the stop (so the overshoot PATH is recorded, not just the edge).
4. **Journal:** `dolphin_uv.stop_watch_events` (NEW table, dolphin_uv namespace ONLY):
ts, trade_id, asset, side, entry_price, breach_price, adverse_pct, spread_bps,
depth_1pct_usd, obf_lag_ms, kind ('BREACH'|'ESCALATION'|'RESOLVED'). RESOLVED row when
the trade's exit appears in trade_events, carrying exit adverse_pct + excess vs 1.2 %.
5. **Daily rollup view:** overshoot count, total excess $, worst trade — the $4.4K audit,
automated forever.
## Iron rules
- ZERO writes outside `dolphin_uv.*`. Never touch BLUE code, HZ contents, or dolphin.*
tables. CH creds read-only usage; INSERT only into dolphin_uv.stop_watch_events.
- Runs under /home/dolphin/siloqy_env, own log file, no hardcoded worktree paths
(derive paths from __file__ / env).
## Tests (doctrine: mutation litmus mandatory)
Synthetic price/position fixtures → exact expected breach/escalation/resolved sequence;
mutation litmus (flip breach comparison, drop escalation step → tests go RED);
lag-measurement honesty test; no-write guard test (any non-dolphin_uv INSERT raises);
determinism (same fixture ⇒ same journal twice).
## Done
Branch `uv/t8-stop-watcher` off current main, fresh clone, push → CI, DONE to Fable with:
branch+sha, test count, a live 30-min run's journal excerpt (real breaches or honest
"no breaches in window"), measured obf_universe lag stats.

View File

@@ -0,0 +1,48 @@
# UV TASK T9 — C10 DITAv2 exec seam (KernelIntent → DITAv2 → BingX VST, DARK)
**Assignee:** cmd-PASS1.2 · **Issuer:** Fable · **Master spec:** §4 C10, §8 fast path.
**Doctrinal kernel:** post-sync vendored dita_v2 per `UV_DITAV2_SOA_VERDICT_20260703.md`
(includes VenueTelemetrySnapshot + zinc venue plane + asex_account.py). Do NOT start
until Fable confirms the vendor sync landed on main (watch the bus).
## Goal
The physical trading path: a UV-owned execution runner that consumes KernelIntents and
drives DITAv2 against BingX **VST testnet** — built now, DARK by default, so that when
PRIME is promoted (post Gates A+B) the only remaining step is arming it with keys.
Pattern precedent: `prod/clean_arch/violet/v4_execution_runner.py` (read for shape; this
is a NEW instance and a NEW module — never reuse a BLUE/PINK/VIOLET runner instance).
## Deliverable: `prod/clean_arch/violet/uv/exec/` (new package)
1. **`intent_source.py`:** KernelIntent inlet. v1 = injectable queue + a file/CLI
injector for dry-run intents (PRIME promotion wiring is a LATER task — leave a
clearly-marked seam, not a stub that pretends).
2. **`seam.py`:** intent → DITAv2 order mapping. EVERY clientOrderId prefixed `u-`
(non-negotiable — this is how UV's orders are distinguishable on the venue forever).
Sizing/leverage passthrough from intent; no local overrides.
3. **`runner.py`:** launcher wiring per dita_v2 `launcher.py` — NEW instance name
(`uv_exec`), zinc venue plane ENABLED (venue_region telemetry is the seam's flight
recorder), jemalloc-friendly long-run posture, no hardcoded paths.
4. **DARK doctrine:** with no keys configured → observe-only: log + journal every intent
and the order it WOULD place (full params), place nothing. `ALLOW_MAINNET=0` is a
hard block: mainnet refuses even if env says otherwise unless a separate operator
arming file exists (two-man rule). VST base URL only.
5. **Journal:** every intent, mapping, would-place/placed, venue telemetry snapshot →
`dolphin_uv.exec_journal` (dolphin_uv namespace ONLY).
## Iron rules
- VST ONLY. DARK until operator arms. `u-` prefix on every clientOrderId.
- Zero writes outside dolphin_uv.*; zero BLUE touches; vendored dita_v2 is read-only
(any kernel change goes upstream + vendor_sync, never in-place).
- Graal-ready NFR-G applies (no CPython-only exotica in the seam layer).
## Tests (mutation litmus mandatory)
Intent→order mapping bit-exact fixtures; `u-` prefix litmus (strip the prefix in code →
test RED); DARK default test (no keys ⇒ zero venue calls — assert at the venue adapter
seam, not by mocking the seam itself); mainnet-block mutation test (force
ALLOW_MAINNET=1 without arming file ⇒ still refuses); venue-plane telemetry presence
test; determinism.
## Done
Branch `uv/t9-exec-seam` off post-sync main, fresh clone (NOT a worktree of the bare),
push → CI, DONE to Fable with: branch+sha, test count, and a dry-run journal excerpt
showing 3 injected intents fully mapped + journaled + zero venue calls.

304
test_pi_wake_agent.py Normal file
View File

@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
Comprehensive test suite for pi_wake_agent.py
Tests all modes, edge cases, and the new succession feature.
"""
import pytest
import subprocess
import time
import tempfile
import os
import sys
from pathlib import Path
from unittest.mock import patch, MagicMock, call
# Add the script directory to path
sys.path.insert(0, "/mnt/dolphinng5_predict")
from pi_wake_agent import (
parse_interval,
interval_to_cron,
interval_to_human,
cron_comment,
parse_sessions,
SCRIPT_PATH,
LOG_FILE,
LOG_MAX_SIZE,
LOG_MAX_FILES,
CRON_COMMENT_PREFIX,
AGENT_NICK,
H5I_AGENT,
H5I_BUS_ROOT,
DEFAULT_INTERVAL,
)
# ─── Test parse_interval ─────────────────────────────────────────────────
class TestParseInterval:
def test_hours(self):
assert parse_interval("1h") == 3600
assert parse_interval("2h") == 7200
assert parse_interval("24h") == 86400
def test_minutes(self):
assert parse_interval("30m") == 1800
assert parse_interval("1m") == 60
assert parse_interval("90m") == 5400
def test_seconds(self):
assert parse_interval("30s") == 30
assert parse_interval("1s") == 1
assert parse_interval("10s") == 10
def test_invalid(self):
with pytest.raises(ValueError):
parse_interval("invalid")
with pytest.raises(ValueError):
parse_interval("1x")
with pytest.raises(ValueError):
parse_interval("")
# ─── Test interval_to_cron ───────────────────────────────────────────────
class TestIntervalToCron:
def test_hours(self):
assert interval_to_cron("1h") == "0 */1 * * *"
assert interval_to_cron("2h") == "0 */2 * * *"
assert interval_to_cron("6h") == "0 */6 * * *"
def test_minutes(self):
assert interval_to_cron("1m") == "*/1 * * * *"
assert interval_to_cron("30m") == "*/30 * * * *"
assert interval_to_cron("45m") == "*/45 * * * *"
def test_invalid_minutes(self):
with pytest.raises(ValueError):
interval_to_cron("60m")
with pytest.raises(ValueError):
interval_to_cron("90m")
def test_invalid_seconds(self):
with pytest.raises(ValueError):
interval_to_cron("30s")
def test_invalid_format(self):
with pytest.raises(ValueError):
interval_to_cron("invalid")
# ─── Test interval_to_human ──────────────────────────────────────────────
class TestIntervalToHuman:
def test_hours(self):
assert interval_to_human("1h") == "1 hour(s)"
assert interval_to_human("2h") == "2 hour(s)"
def test_minutes(self):
assert interval_to_human("30m") == "30 minute(s)"
assert interval_to_human("1m") == "1 minute(s)"
def test_seconds(self):
assert interval_to_human("30s") == "30 second(s)"
assert interval_to_human("1s") == "1 second(s)"
def test_invalid(self):
assert interval_to_human("invalid") == "invalid"
# ─── Test cron_comment ───────────────────────────────────────────────────
class TestCronComment:
def test_single_session(self):
sessions = ["cc_UV_dev0_Fb"]
result = cron_comment(sessions, "1h")
assert result == "pi_wake_agent:cc_UV_dev0_Fb:1h"
def test_multiple_sessions(self):
sessions = ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
result = cron_comment(sessions, "30m")
assert result == "pi_wake_agent:cc_UV_dev0_Fb,cc_UV_dev1_48:30m"
def test_empty_sessions(self):
result = cron_comment([], "1h")
assert result == "pi_wake_agent::1h"
# ─── Test parse_sessions ─────────────────────────────────────────────────
class TestParseSessions:
def test_comma_separated(self):
result = parse_sessions("cc_UV_dev0_Fb,cc_UV_dev1_48")
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
def test_single_session(self):
result = parse_sessions("cc_UV_dev0_Fb")
assert result == ["cc_UV_dev0_Fb"]
def test_with_spaces(self):
result = parse_sessions("cc_UV_dev0_Fb, cc_UV_dev1_48")
assert result == ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]
def test_empty(self):
assert parse_sessions("") == []
assert parse_sessions(None) == []
def test_list_input(self):
result = parse_sessions(["a", "b", "c"])
assert result == ["a", "b", "c"]
def test_filters_empty(self):
result = parse_sessions("a,,b")
assert result == ["a", "b"]
# ─── Test main functions via subprocess ──────────────────────────────────
SCRIPT = "/mnt/dolphinng5_predict/pi_wake_agent.py"
def run_cmd(args, timeout=30):
"""Run the script and return (returncode, stdout, stderr)"""
result = subprocess.run(
[sys.executable, SCRIPT] + args,
capture_output=True,
text=True,
timeout=timeout
)
return result.returncode, result.stdout, result.stderr
# ─── Integration Tests ──────────────────────────────────────────────────
class TestHelp:
def test_help(self):
rc, out, err = run_cmd(["--help"])
assert rc == 0
assert "pi_wake_agent.py" in out
assert "--install" in out
assert "--once" in out
assert "--daemon" in out
assert "--succession" in out
assert "--count" in out
class TestList:
def test_list_no_cron(self):
# Clear any existing cron entries first
subprocess.run(["crontab", "-l"], capture_output=True)
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
rc, out, err = run_cmd(["--list"])
assert rc == 0
assert "pi_wake_agent cron entries" in out
class TestInstallRemove:
def setup_method(self):
# Clear cron before each test
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
def teardown_method(self):
subprocess.run("crontab -l 2>/dev/null | grep -v pi_wake_agent | crontab -", shell=True, check=False)
def test_install_and_list(self):
rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "test_session", "--msg", "test"])
assert rc == 0
rc, out, err = run_cmd(["--list"])
assert rc == 0
assert "test_session" in out
assert "1h" in out
# Cleanup
run_cmd(["--remove", "--session", "test_session", "--interval", "1h"])
def test_install_multi_session(self):
rc, out, err = run_cmd(["--install", "--interval", "30m", "--sessions", "s1,s2", "--msg", "multi"])
assert rc == 0
rc, out, err = run_cmd(["--list"])
assert rc == 0
assert "s1,s2" in out
run_cmd(["--remove", "--sessions", "s1,s2", "--interval", "30m"])
class TestValidate:
def test_validate_existing(self):
# The test session might not exist, so we test the command runs
rc, out, err = run_cmd(["--validate", "--session", "pi_test"])
assert rc == 0 # Should not crash
class TestOnce:
def test_once_short(self):
# Use a very short interval
rc, out, err = run_cmd(["--once", "--interval", "1s", "--session", "test_session", "--msg", "quick test"], timeout=10)
assert rc == 0
# Check log file for the message
time.sleep(2)
log_content = Path("/tmp/pi_wake_agent.log").read_text()
assert "One-shot timer set" in log_content
class TestSuccession:
def test_succession_short(self):
# Test succession mode with very short intervals
rc, out, err = run_cmd([
"--succession", "--count", "2", "--interval", "1s",
"--session", "test_session", "--msg", "succession test"
], timeout=60)
assert rc == 0
# Check logs
time.sleep(3)
log_content = Path("/tmp/pi_wake_agent.log").read_text()
assert "SUCCESSION START" in log_content
assert "SUCCESSION COMPLETE" in log_content
def test_succession_invalid_count(self):
rc, out, err = run_cmd(["--succession", "--count", "0", "--interval", "1s", "--session", "test"])
# Should fail with invalid count
assert rc != 0 or "error" in err.lower()
class TestStatus:
def test_status(self):
rc, out, err = run_cmd(["--status"])
assert rc == 0
assert "pi_wake_agent Status" in out
assert "Script:" in out
class TestRun:
def test_run_mode(self):
# This is the internal mode called by cron
rc, out, err = run_cmd(["--run", "--session", "test_session", "--msg", "test"])
assert rc == 0 # Should succeed even if session doesn't exist
class TestEdgeCases:
def test_invalid_interval(self):
rc, out, err = run_cmd(["--install", "--interval", "invalid", "--session", "test"])
assert rc != 0
def test_missing_session(self):
rc, out, err = run_cmd(["--install", "--interval", "1h"])
assert rc != 0
assert "session" in err.lower() or "required" in err.lower()
def test_invalid_count(self):
rc, out, err = run_cmd(["--succession", "--count", "-1", "--interval", "1s", "--session", "test"])
assert rc != 0
class TestSessionParsing:
def test_multiple_session_flags(self):
rc, out, err = run_cmd(["--install", "--interval", "1h", "--session", "s1", "--session", "s2", "--msg", "test"])
assert rc == 0
run_cmd(["--remove", "--session", "s1", "--interval", "1h"])
run_cmd(["--remove", "--session", "s2", "--interval", "1h"])
class TestRemove:
def test_remove_nonexistent(self):
# Should not crash
rc, out, err = run_cmd(["--remove", "--session", "nonexistent", "--interval", "1h"])
assert rc == 0
# ─── Test Logging ─────────────────────────────────────────────────────────
class TestLogging:
def test_log_file_created(self):
run_cmd(["--install", "--interval", "1h", "--session", "log_test", "--msg", "test"])
assert LOG_FILE.exists()
run_cmd(["--remove", "--session", "log_test", "--interval", "1h"])
# ─── Run all tests ────────────────────────────────────────────────────────
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])