Research-Stack/4-Infrastructure/shim/stellar_gas_sandpile_graph_replay.py
2026-05-11 22:08:10 -05:00

444 lines
15 KiB
Python

#!/usr/bin/env python3
"""Graph replay hardening for the stellar-gas sandpile diagnostic.
This converts the existing sandpile metaphor into a reproducible graph
diagnostic. It is a toppling proxy over sky/redshift cells, not a physical
sandpile simulation and not a claim about stellar gas mechanics.
"""
from __future__ import annotations
import hashlib
import json
import math
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
SANDPILE_JSON = ROOT / "shared-data/data/stellar_gas_observation/stellar_gas_abelian_sandpile_probe.json"
FINE_ZOOM_JSON = ROOT / "shared-data/data/stellar_gas_observation/stellar_gas_sandpile_fine_zoom.json"
OUT_DIR = ROOT / "shared-data/data/stellar_gas_observation"
DOCS_DIR = ROOT / "6-Documentation/docs"
TIDDLER_DIR = ROOT / "6-Documentation/tiddlywiki-local/wiki/tiddlers"
OUT_JSON = OUT_DIR / "stellar_gas_sandpile_graph_replay.json"
RECEIPT_JSON = OUT_DIR / "stellar_gas_sandpile_graph_replay_receipt.json"
DOC_MD = DOCS_DIR / "stellar_gas_sandpile_graph_replay_2026-05-09.md"
TIDDLER = TIDDLER_DIR / "Stellar Gas Sandpile Graph Replay.tid"
Z_BIN_ORDER = {
"z_000_002": 0,
"z_002_004": 1,
"z_004_006": 2,
"z_006_008": 3,
"z_008_plus": 4,
}
def load_json(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as f:
return json.load(f)
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def sha256_payload(payload: Any) -> str:
raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def parse_cell(cell: str) -> dict[str, Any]:
sky_sector, z_bin = cell.split("__", 1)
ra_sector, hemisphere = sky_sector.split("_", 1)
return {
"cell": cell,
"sky_sector": sky_sector,
"ra_sector": int(ra_sector.removeprefix("ra")),
"hemisphere": hemisphere,
"z_bin": z_bin,
"z_bin_index": Z_BIN_ORDER[z_bin],
}
def are_adjacent(a: dict[str, Any], b: dict[str, Any]) -> bool:
same_z = a["z_bin_index"] == b["z_bin_index"]
same_ra = a["ra_sector"] == b["ra_sector"]
same_hemisphere = a["hemisphere"] == b["hemisphere"]
ra_neighbor = same_z and same_hemisphere and abs(a["ra_sector"] - b["ra_sector"]) == 1
hemisphere_neighbor = same_z and same_ra and a["hemisphere"] != b["hemisphere"]
redshift_neighbor = same_ra and same_hemisphere and abs(a["z_bin_index"] - b["z_bin_index"]) == 1
return ra_neighbor or hemisphere_neighbor or redshift_neighbor
def round9(value: float) -> float:
return round(value, 9)
def seed_counts(fine_zoom: dict[str, Any]) -> dict[str, int]:
return {
cell["cell"]: int(cell["candidate_count"])
for cell in fine_zoom["candidate_cells"]
}
def node_rows(sandpile: dict[str, Any], fine_zoom: dict[str, Any]) -> list[dict[str, Any]]:
counts = seed_counts(fine_zoom)
index_std = float(sandpile["toppling_index_summary"]["std"]) or 1.0
rows = []
for row in sorted(sandpile["top_cells"], key=lambda item: item["cell"]):
parsed = parse_cell(row["cell"])
proxy_z = float(row["sandpile"]["toppling_index"]) / index_std
rows.append(
{
**parsed,
"state": row["sandpile"]["state"],
"grains": float(row["sandpile"]["grains"]),
"toppling_pressure": float(row["sandpile"]["toppling_pressure"]),
"toppling_index": float(row["sandpile"]["toppling_index"]),
"toppling_index_z_proxy": round9(proxy_z),
"candidate_object_count": counts.get(row["cell"], 0),
}
)
return rows
def graph_edges(rows: list[dict[str, Any]]) -> list[dict[str, str]]:
edges = []
for i, left in enumerate(rows):
for right in rows[i + 1 :]:
if are_adjacent(left, right):
edges.append({"source": left["cell"], "target": right["cell"]})
return edges
def adjacency(nodes: list[str], edges: list[dict[str, str]]) -> dict[str, list[str]]:
out = {node: [] for node in nodes}
for edge in edges:
out[edge["source"]].append(edge["target"])
out[edge["target"]].append(edge["source"])
return {node: sorted(neighbors) for node, neighbors in out.items()}
def threshold_and_grain_tables(rows: list[dict[str, Any]], adj: dict[str, list[str]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
threshold_table = []
grain_table = []
for row in rows:
cell = row["cell"]
degree = len(adj[cell])
threshold = max(1, degree + 1)
proxy_z = max(0.0, row["toppling_index_z_proxy"])
initial_grains = max(0, math.ceil(proxy_z * threshold))
threshold_table.append(
{
"cell": cell,
"degree": degree,
"toppling_threshold": threshold,
"threshold_rule": "degree_plus_one_toppling_proxy",
}
)
grain_table.append(
{
"cell": cell,
"initial_grains": initial_grains,
"grain_rule": "ceil(max(0, toppling_index_z_proxy) * threshold)",
"toppling_index_z_proxy": row["toppling_index_z_proxy"],
"seed_object_count": row["candidate_object_count"],
"source_state": row["state"],
}
)
return threshold_table, grain_table
def replay_one(seed: str, adj: dict[str, list[str]], thresholds: dict[str, int], grains: dict[str, int]) -> dict[str, Any]:
working = dict(grains)
topple_count = {cell: 0 for cell in working}
touched = set()
q: deque[str] = deque([seed])
max_steps = max(1, len(working) * 20)
steps = 0
while q and steps < max_steps:
cell = q.popleft()
if working[cell] < thresholds[cell]:
continue
steps += 1
touched.add(cell)
topple_count[cell] += 1
working[cell] -= thresholds[cell]
for neighbor in adj[cell]:
working[neighbor] += 1
if working[neighbor] >= thresholds[neighbor]:
q.append(neighbor)
toppled_cells = sorted(cell for cell, count in topple_count.items() if count)
return {
"seed_cell": seed,
"terminated": not q,
"step_limit": max_steps,
"topple_events": sum(topple_count.values()),
"avalanche_size_cells": len(toppled_cells),
"toppled_cells": toppled_cells,
"final_seed_grains": working[seed],
}
def build() -> tuple[dict[str, Any], dict[str, Any]]:
sandpile = load_json(SANDPILE_JSON)
fine_zoom = load_json(FINE_ZOOM_JSON)
rows = node_rows(sandpile, fine_zoom)
nodes = [row["cell"] for row in rows]
edges = graph_edges(rows)
adj = adjacency(nodes, edges)
threshold_table, grain_table = threshold_and_grain_tables(rows, adj)
thresholds = {row["cell"]: int(row["toppling_threshold"]) for row in threshold_table}
grains = {row["cell"]: int(row["initial_grains"]) for row in grain_table}
seed_cells = sorted(
row["cell"]
for row in rows
if row["state"] == "AVALANCHE_CANDIDATE" and row["candidate_object_count"] > 0
)
avalanches = [replay_one(seed, adj, thresholds, grains) for seed in seed_cells]
canonical_graph = {
"adjacency_rule": (
"Edges join same-redshift neighboring RA sectors, same-RA north/south sectors, "
"or same-sky-sector adjacent redshift bins."
),
"nodes": [
{
"cell": row["cell"],
"ra_sector": row["ra_sector"],
"hemisphere": row["hemisphere"],
"z_bin": row["z_bin"],
}
for row in rows
],
"edges": edges,
"threshold_table": threshold_table,
"initial_grain_table": grain_table,
}
graph_hash = sha256_payload(canonical_graph)
replay_payload = {
"graph_hash": graph_hash,
"seed_cells": seed_cells,
"avalanche_replay": avalanches,
}
replay_hash = sha256_payload(replay_payload)
created = now_iso()
result = {
"schema": "stellar_gas_sandpile_graph_replay_v0",
"created": created,
"decision": "ADMIT_GRAPH_TOPPLING_PROXY_HOLD_PHYSICAL_SANDPILE_SIMULATION",
"claim_boundary": (
"This is a reproducible graph diagnostic and toppling proxy over existing "
"stellar-gas evidence cells. It is not a physical sandpile simulation, "
"not a stellar-gas mechanism proof, and not a cosmology fit."
),
"sources": {
"sandpile_probe": str(SANDPILE_JSON.relative_to(ROOT)),
"fine_zoom_examples": str(FINE_ZOOM_JSON.relative_to(ROOT)),
},
"source_hashes": {
"sandpile_probe_sha256": sha256_file(SANDPILE_JSON),
"fine_zoom_examples_sha256": sha256_file(FINE_ZOOM_JSON),
},
"seed_evidence": {
"avalanche_cell_count": len(seed_cells),
"candidate_object_count": int(fine_zoom["rows_in_candidate_cells"]),
"candidate_counts_by_cell": seed_counts(fine_zoom),
},
"adjacency_rule": canonical_graph["adjacency_rule"],
"graph_hash": graph_hash,
"replay_hash": replay_hash,
"node_count": len(nodes),
"edge_count": len(edges),
"nodes": rows,
"edges": edges,
"adjacency": adj,
"toppling_threshold_table": threshold_table,
"initial_grain_table": grain_table,
"avalanche_sizes": [
{
"seed_cell": row["seed_cell"],
"avalanche_size_cells": row["avalanche_size_cells"],
"topple_events": row["topple_events"],
"terminated": row["terminated"],
}
for row in avalanches
],
"avalanche_replay": avalanches,
"holds": [
"HOLD_PHYSICAL_SANDPILE_SIMULATION",
"HOLD_STELLAR_GAS_MECHANISM_PROOF",
"HOLD_DIRECT_STELLAR_MASS",
"HOLD_DIRECT_GAS_DENSITY_INFERENCE",
"HOLD_COSMOLOGY_FIT",
],
}
receipt = {
"receipt_type": "stellar_gas_sandpile_graph_replay_receipt",
"created": created,
"decision": result["decision"],
"graph_hash": graph_hash,
"replay_hash": replay_hash,
"node_count": len(nodes),
"edge_count": len(edges),
"seed_avalanche_cell_count": len(seed_cells),
"seed_candidate_object_count": int(fine_zoom["rows_in_candidate_cells"]),
"avalanche_sizes": result["avalanche_sizes"],
"validated_outputs": [
str(OUT_JSON.relative_to(ROOT)),
str(RECEIPT_JSON.relative_to(ROOT)),
str(DOC_MD.relative_to(ROOT)),
str(TIDDLER.relative_to(ROOT)),
],
}
return result, receipt
def write_docs(result: dict[str, Any], receipt: dict[str, Any]) -> None:
threshold_lines = "\n".join(
f"| `{row['cell']}` | {row['degree']} | {row['toppling_threshold']} |"
for row in result["toppling_threshold_table"]
)
grain_lines = "\n".join(
f"| `{row['cell']}` | {row['initial_grains']} | {row['toppling_index_z_proxy']} | {row['seed_object_count']} |"
for row in result["initial_grain_table"]
)
avalanche_lines = "\n".join(
f"| `{row['seed_cell']}` | {row['avalanche_size_cells']} | {row['topple_events']} | `{row['terminated']}` |"
for row in result["avalanche_sizes"]
)
holds = "\n".join(f"- `{hold}`" for hold in result["holds"])
DOC_MD.write_text(
f"""# Stellar Gas Sandpile Graph Replay
Status: `GRAPH_TOPPLING_PROXY`
Decision: `{result['decision']}`
This hardening pass turns the sandpile metaphor into a reproducible graph
diagnostic. Nodes are sky/redshift cells. Edges are defined by sky-sector
neighbors plus adjacent redshift bins. Grain and toppling values are diagnostic
proxies derived from the existing cell-level toppling index.
Claim boundary: this is not a physical sandpile simulation, not a stellar-gas
mechanism proof, not direct stellar mass, not gas density inference, and not a
cosmology fit.
## Replay Receipt
```json
{json.dumps(receipt, indent=2, sort_keys=True)}
```
## Seed Evidence
```text
avalanche cells: {result['seed_evidence']['avalanche_cell_count']}
candidate objects: {result['seed_evidence']['candidate_object_count']}
graph nodes: {result['node_count']}
graph edges: {result['edge_count']}
graph hash: {result['graph_hash']}
replay hash: {result['replay_hash']}
```
## Toppling Threshold Table
| cell | degree | threshold |
| --- | ---: | ---: |
{threshold_lines}
## Initial Grain Table
| cell | initial grains | index z proxy | seed objects |
| --- | ---: | ---: | ---: |
{grain_lines}
## Avalanche Sizes
| seed cell | size cells | topple events | terminated |
| --- | ---: | ---: | --- |
{avalanche_lines}
## Holds
{holds}
""",
encoding="utf-8",
)
TIDDLER.write_text(
f"""title: Stellar Gas Sandpile Graph Replay
tags: StellarGasObservation SemanticMassNumbers Sandpile GraphReplay Receipts
type: text/vnd.tiddlywiki
Status: <<tag GRAPH_TOPPLING_PROXY>>
Decision: `{result['decision']}`
This tiddler records the graph/replay hardening of the stellar-gas sandpile
diagnostic. It is a toppling proxy over evidence cells, not a physical sandpile
simulation or mechanism proof.
```
avalanche cells: {result['seed_evidence']['avalanche_cell_count']}
candidate objects: {result['seed_evidence']['candidate_object_count']}
graph nodes: {result['node_count']}
graph edges: {result['edge_count']}
graph hash: {result['graph_hash']}
replay hash: {result['replay_hash']}
```
!! Toppling Threshold Table
|cell | degree | threshold |
|---|---:|---:|
{threshold_lines}
!! Initial Grain Table
|cell | initial grains | index z proxy | seed objects |
|---|---:|---:|---:|
{grain_lines}
!! Avalanche Sizes
|seed cell | size cells | topple events | terminated |
|---|---:|---:|---|
{avalanche_lines}
!! Boundary
Diagnostic/toppling proxy only. Holds: {", ".join(result["holds"])}.
""",
encoding="utf-8",
)
def main() -> None:
result, receipt = build()
OUT_DIR.mkdir(parents=True, exist_ok=True)
DOCS_DIR.mkdir(parents=True, exist_ok=True)
TIDDLER_DIR.mkdir(parents=True, exist_ok=True)
OUT_JSON.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8")
RECEIPT_JSON.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n", encoding="utf-8")
write_docs(result, receipt)
print(json.dumps(receipt, indent=2, sort_keys=True))
if __name__ == "__main__":
main()