338 lines
15 KiB
Python
338 lines
15 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Read-only Cohort001 entry-to-frozen-oracle failure mining.
|
||
|
|
|
||
|
|
Associations in this report are observational and must not be interpreted as
|
||
|
|
causal feature effects. The runner never replays a strategy or alters inputs.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
from collections import Counter, defaultdict
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
|
||
|
|
|
||
|
|
ARTIFACT = "COHORT001_FEATURE_FAILURE_MINING_V1"
|
||
|
|
GOOD_LABEL = "GOOD_ENTRY"
|
||
|
|
FAILURE_LABELS = ("WRONG_DIRECTION", "HIGH_MAE_ENTRY", "LATE_SIGNAL", "RECOVERY_DEPENDENT")
|
||
|
|
ENTRY_FIELDS = ("entry_bar", "entry_index", "entry_bar_index", "entry_idx", "bar_index")
|
||
|
|
|
||
|
|
|
||
|
|
def file_hash(path: Path) -> str:
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
with path.open("rb") as source:
|
||
|
|
for block in iter(lambda: source.read(1024 * 1024), b""):
|
||
|
|
digest.update(block)
|
||
|
|
return digest.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def directory_hash(path: Path) -> str:
|
||
|
|
"""Hash names and contents without loading the oracle into memory."""
|
||
|
|
digest = hashlib.sha256()
|
||
|
|
for item in sorted(path.glob("*.npy")):
|
||
|
|
digest.update(item.name.encode("utf-8"))
|
||
|
|
digest.update(b"\0")
|
||
|
|
digest.update(bytes.fromhex(file_hash(item)))
|
||
|
|
return digest.hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def json_compatible(value: Any) -> Any:
|
||
|
|
"""Convert NumPy values to strict JSON-compatible Python values."""
|
||
|
|
if isinstance(value, np.ndarray):
|
||
|
|
return json_compatible(value.tolist())
|
||
|
|
if isinstance(value, np.bool_):
|
||
|
|
return bool(value)
|
||
|
|
if isinstance(value, np.integer):
|
||
|
|
return int(value)
|
||
|
|
if isinstance(value, np.floating):
|
||
|
|
value = float(value)
|
||
|
|
if isinstance(value, float):
|
||
|
|
return value if np.isfinite(value) else None
|
||
|
|
if isinstance(value, dict):
|
||
|
|
return {json_compatible(key): json_compatible(item) for key, item in value.items()}
|
||
|
|
if isinstance(value, (list, tuple)):
|
||
|
|
return [json_compatible(item) for item in value]
|
||
|
|
return value
|
||
|
|
|
||
|
|
|
||
|
|
def load_engineering_map(path: Path) -> dict[str, dict[str, Any]]:
|
||
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
|
|
rows = payload.get("primitives", payload.get("requests", []))
|
||
|
|
if not isinstance(rows, list):
|
||
|
|
raise ValueError("engineering map must contain primitives or requests")
|
||
|
|
result = {}
|
||
|
|
for row in rows:
|
||
|
|
try:
|
||
|
|
key = feature_key(row)
|
||
|
|
except (KeyError, TypeError, ValueError):
|
||
|
|
continue
|
||
|
|
if key in result:
|
||
|
|
raise ValueError(f"duplicate engineering primitive: {key}")
|
||
|
|
result[key] = dict(row, feature_key=key)
|
||
|
|
if not result:
|
||
|
|
raise ValueError("engineering map contains no usable primitives")
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def feature_key(row: dict[str, Any]) -> str:
|
||
|
|
return f"{int(row['indicator_id'])}:{int(row['period'])}:{float(row['p1']):g}"
|
||
|
|
|
||
|
|
|
||
|
|
def load_semantics(path: Path, engineering: dict[str, dict[str, Any]]) -> dict[str, dict[str, str]]:
|
||
|
|
try:
|
||
|
|
import pyarrow.parquet as pq
|
||
|
|
except ImportError as error:
|
||
|
|
raise ValueError("pyarrow is required to read semantic-map parquet") from error
|
||
|
|
result = {key: {"domain": "unclassified", "output_type": "continuous"} for key in engineering}
|
||
|
|
for row in pq.read_table(path).to_pylist():
|
||
|
|
try:
|
||
|
|
key = str(row.get("feature_key") or feature_key(row))
|
||
|
|
except (KeyError, TypeError, ValueError):
|
||
|
|
continue
|
||
|
|
if key not in result:
|
||
|
|
continue
|
||
|
|
raw = str(row.get("output_type", row.get("semantic_type", row.get("value_type", "continuous")))).lower()
|
||
|
|
kind = "event" if raw in {"event", "detection", "binary_event"} else "state" if raw in {"state", "categorical", "boolean"} else "continuous"
|
||
|
|
result[key] = {"domain": str(row.get("domain", row.get("semantic_domain", row.get("engineering_family", row.get("family", "unclassified"))))), "output_type": kind}
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def feature_keys_from_row(row: dict[str, Any]) -> list[str]:
|
||
|
|
"""Read explicit triples first, then the persisted flat five-triple genome."""
|
||
|
|
for field in ("feature_triples_json", "feature_triples"):
|
||
|
|
value = row.get(field)
|
||
|
|
if isinstance(value, str):
|
||
|
|
try:
|
||
|
|
value = json.loads(value)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
continue
|
||
|
|
if isinstance(value, list):
|
||
|
|
keys = []
|
||
|
|
for triple in value:
|
||
|
|
if isinstance(triple, dict):
|
||
|
|
try:
|
||
|
|
keys.append(feature_key(triple))
|
||
|
|
except (KeyError, TypeError, ValueError):
|
||
|
|
pass
|
||
|
|
if keys:
|
||
|
|
return keys
|
||
|
|
value = row.get("genome_json", row.get("genome"))
|
||
|
|
if isinstance(value, str):
|
||
|
|
try:
|
||
|
|
value = json.loads(value)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
return []
|
||
|
|
combo = value.get("combo") if isinstance(value, dict) else None
|
||
|
|
if isinstance(combo, str):
|
||
|
|
try:
|
||
|
|
combo = json.loads(combo)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
return []
|
||
|
|
if not isinstance(combo, list) or len(combo) < 15:
|
||
|
|
return []
|
||
|
|
try:
|
||
|
|
return [f"{int(combo[index])}:{int(combo[index + 1])}:{float(combo[index + 2]):g}" for index in range(0, 15, 3)]
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
return []
|
||
|
|
|
||
|
|
|
||
|
|
def entry_bar(row: dict[str, Any]) -> tuple[int | None, str | None]:
|
||
|
|
"""Resolve only an integer bar offset; timestamps are intentionally not guessed."""
|
||
|
|
containers = [("column", row)]
|
||
|
|
for field in ("raw_ledger_json", "context_json"):
|
||
|
|
value = row.get(field)
|
||
|
|
if isinstance(value, str):
|
||
|
|
try:
|
||
|
|
value = json.loads(value)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
continue
|
||
|
|
if isinstance(value, dict):
|
||
|
|
containers.append((field, value))
|
||
|
|
for source, values in containers:
|
||
|
|
for field in ENTRY_FIELDS:
|
||
|
|
value = values.get(field)
|
||
|
|
if isinstance(value, bool) or value is None:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
numeric = float(value)
|
||
|
|
except (TypeError, ValueError):
|
||
|
|
continue
|
||
|
|
if np.isfinite(numeric) and numeric.is_integer() and numeric >= 0:
|
||
|
|
return int(numeric), f"{source}.{field}"
|
||
|
|
return None, None
|
||
|
|
|
||
|
|
|
||
|
|
def checkpoint_paths(directory: Path, engineering: dict[str, dict[str, Any]]) -> dict[str, Path]:
|
||
|
|
paths = {}
|
||
|
|
for path in directory.glob("*.npy"):
|
||
|
|
parts = path.stem.split("_")
|
||
|
|
if len(parts) != 4 or parts[0] != "hs22":
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
key = f"{int(parts[1])}:{int(parts[2])}:{float(parts[3]):g}"
|
||
|
|
except ValueError:
|
||
|
|
continue
|
||
|
|
if key in engineering and key not in paths:
|
||
|
|
paths[key] = path
|
||
|
|
return paths
|
||
|
|
|
||
|
|
|
||
|
|
def ks_statistic(good: np.ndarray, failure: np.ndarray) -> tuple[float, str]:
|
||
|
|
try:
|
||
|
|
from scipy.stats import ks_2samp
|
||
|
|
|
||
|
|
return float(ks_2samp(good, failure, method="auto").statistic), "scipy_ks_2samp"
|
||
|
|
except ImportError:
|
||
|
|
# Exact empirical CDF distance evaluated at every observed rank boundary.
|
||
|
|
values = np.sort(np.concatenate((good, failure)))
|
||
|
|
left = np.searchsorted(np.sort(good), values, side="right") / good.size
|
||
|
|
right = np.searchsorted(np.sort(failure), values, side="right") / failure.size
|
||
|
|
return float(np.max(np.abs(left - right))), "exact_rank_approx"
|
||
|
|
|
||
|
|
|
||
|
|
def odds_ratio(good: np.ndarray, failure: np.ndarray) -> float | None:
|
||
|
|
if not np.all(np.isin(np.concatenate((good, failure)), (0, 1))):
|
||
|
|
return None
|
||
|
|
# Haldane-Anscombe correction keeps complete separation reportable.
|
||
|
|
good_on, failure_on = good.sum(), failure.sum()
|
||
|
|
return float(((failure_on + 0.5) / (failure.size - failure_on + 0.5)) / ((good_on + 0.5) / (good.size - good_on + 0.5)))
|
||
|
|
|
||
|
|
|
||
|
|
def comparison(good: list[tuple[float, str]], failure: list[tuple[float, str]]) -> dict[str, Any]:
|
||
|
|
good_values, failure_values = np.array([item[0] for item in good]), np.array([item[0] for item in failure])
|
||
|
|
median_difference = float(np.median(failure_values) - np.median(good_values))
|
||
|
|
pooled = np.concatenate((good_values, failure_values))
|
||
|
|
good_variance = np.var(good_values, ddof=1) if good_values.size > 1 else 0.0
|
||
|
|
failure_variance = np.var(failure_values, ddof=1) if failure_values.size > 1 else 0.0
|
||
|
|
degrees_of_freedom = good_values.size + failure_values.size - 2
|
||
|
|
scale = float(np.sqrt(((good_values.size - 1) * good_variance + (failure_values.size - 1) * failure_variance) / degrees_of_freedom)) if degrees_of_freedom else 0.0
|
||
|
|
effect = median_difference / scale if scale else None
|
||
|
|
pooled_sorted = np.sort(pooled)
|
||
|
|
quantile_separation = float(
|
||
|
|
np.searchsorted(pooled_sorted, np.median(failure_values), side="right") / pooled.size
|
||
|
|
- np.searchsorted(pooled_sorted, np.median(good_values), side="right") / pooled.size
|
||
|
|
)
|
||
|
|
ks, ks_method = ks_statistic(good_values, failure_values)
|
||
|
|
direction = np.sign(median_difference)
|
||
|
|
folds = []
|
||
|
|
for fold in sorted(set(item[1] for item in good) & set(item[1] for item in failure)):
|
||
|
|
fold_difference = np.median([item[0] for item in failure if item[1] == fold]) - np.median([item[0] for item in good if item[1] == fold])
|
||
|
|
folds.append(float(fold_difference))
|
||
|
|
consistent = sum(np.sign(item) == direction for item in folds) if direction else sum(item == 0 for item in folds)
|
||
|
|
return {
|
||
|
|
"good_samples": int(good_values.size), "failure_samples": int(failure_values.size),
|
||
|
|
"median_difference_failure_minus_good": median_difference,
|
||
|
|
"standardized_effect_size": effect, "standardized_effect_size_method": "median_difference_over_pooled_within_group_standard_deviation", "quantile_separation": quantile_separation,
|
||
|
|
"ks_statistic": ks, "ks_method": ks_method,
|
||
|
|
"folds_compared": len(folds), "fold_direction_consistent": consistent,
|
||
|
|
"fold_consistency": consistent / len(folds) if folds else None,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def mine(rows: list[dict[str, Any]], engineering: dict[str, dict[str, Any]], semantics: dict[str, dict[str, str]], paths: dict[str, Path]) -> tuple[list[dict[str, Any]], dict[str, int], list[str]]:
|
||
|
|
samples: dict[str, dict[str, list[tuple[float, str]]]] = defaultdict(lambda: defaultdict(list))
|
||
|
|
skipped = Counter()
|
||
|
|
schemas = set()
|
||
|
|
arrays: dict[str, np.ndarray] = {}
|
||
|
|
for row in rows:
|
||
|
|
label = row.get("failure_label")
|
||
|
|
if label not in (GOOD_LABEL, *FAILURE_LABELS):
|
||
|
|
continue
|
||
|
|
bar, schema = entry_bar(row)
|
||
|
|
keys = feature_keys_from_row(row)
|
||
|
|
if bar is None:
|
||
|
|
skipped["unmappable_entry_bar"] += 1
|
||
|
|
continue
|
||
|
|
if not keys:
|
||
|
|
skipped["unmappable_feature_triples"] += 1
|
||
|
|
continue
|
||
|
|
schemas.add(schema)
|
||
|
|
fold = str(row.get("fold", "unknown"))
|
||
|
|
for key in set(keys):
|
||
|
|
if key not in engineering or key not in paths:
|
||
|
|
skipped["feature_missing_from_oracle"] += 1
|
||
|
|
continue
|
||
|
|
values = arrays.setdefault(key, np.load(paths[key], allow_pickle=False, mmap_mode="r").reshape(-1))
|
||
|
|
if bar >= values.size:
|
||
|
|
skipped["entry_bar_outside_oracle"] += 1
|
||
|
|
continue
|
||
|
|
value = float(values[bar])
|
||
|
|
if not np.isfinite(value):
|
||
|
|
skipped["nonfinite_oracle_value"] += 1
|
||
|
|
continue
|
||
|
|
samples[key][label].append((value, fold))
|
||
|
|
output = []
|
||
|
|
for key in sorted(samples):
|
||
|
|
good = samples[key][GOOD_LABEL]
|
||
|
|
if not good:
|
||
|
|
continue
|
||
|
|
for label in FAILURE_LABELS:
|
||
|
|
failure = samples[key][label]
|
||
|
|
if not failure:
|
||
|
|
continue
|
||
|
|
result = {"feature_key": key, "failure_label": label, "attribution": "ASSOCIATIVE_NOT_CAUSAL", **engineering[key], **semantics[key], **comparison(good, failure)}
|
||
|
|
if semantics[key]["output_type"] in {"state", "event"}:
|
||
|
|
result["state_event_odds_ratio_failure_vs_good"] = odds_ratio(np.array([x[0] for x in good]), np.array([x[0] for x in failure]))
|
||
|
|
output.append(result)
|
||
|
|
return output, dict(sorted(skipped.items())), sorted(schema for schema in schemas if schema)
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> None:
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--failure-context", type=Path, required=True)
|
||
|
|
parser.add_argument("--semantic-map", type=Path, required=True)
|
||
|
|
parser.add_argument("--oracle-checkpoint-dir", type=Path, required=True)
|
||
|
|
parser.add_argument("--engineering-map", type=Path, required=True)
|
||
|
|
parser.add_argument("--output-dir", type=Path, required=True)
|
||
|
|
args = parser.parse_args()
|
||
|
|
if not args.oracle_checkpoint_dir.is_dir():
|
||
|
|
parser.error("--oracle-checkpoint-dir must be a directory")
|
||
|
|
for path in (args.failure_context, args.semantic_map, args.engineering_map):
|
||
|
|
if not path.is_file():
|
||
|
|
parser.error(f"input is not a file: {path}")
|
||
|
|
try:
|
||
|
|
import pyarrow as pa
|
||
|
|
import pyarrow.parquet as pq
|
||
|
|
except ImportError as error:
|
||
|
|
raise SystemExit("pyarrow is required for Cohort001 failure mining") from error
|
||
|
|
engineering = load_engineering_map(args.engineering_map)
|
||
|
|
semantics = load_semantics(args.semantic_map, engineering)
|
||
|
|
paths = checkpoint_paths(args.oracle_checkpoint_dir, engineering)
|
||
|
|
rows = pq.read_table(args.failure_context).to_pylist()
|
||
|
|
features, skipped, entry_schemas = mine(rows, engineering, semantics, paths)
|
||
|
|
domains: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||
|
|
for item in features:
|
||
|
|
domains[(item["domain"], item["failure_label"])].append(item)
|
||
|
|
domain_rows = [{
|
||
|
|
"domain": domain, "failure_label": label, "feature_comparisons": len(items),
|
||
|
|
"median_standardized_effect_size": float(np.median([x["standardized_effect_size"] for x in items if x["standardized_effect_size"] is not None])) if any(x["standardized_effect_size"] is not None for x in items) else None,
|
||
|
|
"median_ks_statistic": float(np.median([x["ks_statistic"] for x in items])),
|
||
|
|
"median_fold_consistency": float(np.median([x["fold_consistency"] for x in items if x["fold_consistency"] is not None])) if any(x["fold_consistency"] is not None for x in items) else None,
|
||
|
|
"attribution": "ASSOCIATIVE_NOT_CAUSAL",
|
||
|
|
} for (domain, label), items in sorted(domains.items())]
|
||
|
|
result = {
|
||
|
|
"schema_version": 1, "artifact": ARTIFACT, "read_only": True,
|
||
|
|
"attribution": "ASSOCIATIVE_NOT_CAUSAL",
|
||
|
|
"attribution_note": "Entry-time associations do not establish causal feature effects.",
|
||
|
|
"inputs": {
|
||
|
|
str(path): file_hash(path) for path in (args.failure_context, args.semantic_map, args.engineering_map)
|
||
|
|
} | {str(args.oracle_checkpoint_dir): directory_hash(args.oracle_checkpoint_dir)},
|
||
|
|
"entry_bar_schema_detected": entry_schemas,
|
||
|
|
"failure_context_rows": len(rows), "oracle_features_available": len(paths),
|
||
|
|
"skipped": skipped, "feature_failure_comparisons": features, "domain_aggregates": domain_rows,
|
||
|
|
}
|
||
|
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
|
pq.write_table(pa.Table.from_pylist(features), args.output_dir / "cohort001_feature_failure_mining_v1.parquet", compression="zstd")
|
||
|
|
output = args.output_dir / "cohort001_feature_failure_mining_v1.json"
|
||
|
|
output.write_text(json.dumps(json_compatible(result), indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
|
||
|
|
print(output)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|