mirror of
https://github.com/elder-plinius/OBLITERATUS.git
synced 2026-07-19 10:27:38 +02:00
310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""Community contribution system for crowdsourced paper data.
|
|
|
|
Enables users to contribute anonymized experiment results to the shared
|
|
paper dataset. Unlike telemetry (which is fire-and-forget to a remote
|
|
endpoint), contributions are saved as local JSON files that can be
|
|
submitted via pull request to the community results repository.
|
|
|
|
Usage:
|
|
from obliteratus.community import save_contribution
|
|
|
|
# After running a pipeline:
|
|
path = save_contribution(
|
|
pipeline,
|
|
model_name="meta-llama/Llama-2-7b-chat-hf", # public model ID
|
|
notes="Ran on A100 with default prompts",
|
|
)
|
|
# Generates: community_results/llama2-7b_advanced_20260227_143052.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from obliteratus.telemetry import (
|
|
_direction_stats,
|
|
_extract_excise_details,
|
|
_extract_prompt_counts,
|
|
_extract_stage_durations,
|
|
_get_peak_vram,
|
|
_safe_float,
|
|
build_report,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Schema version for community contributions (extends telemetry schema v2)
|
|
CONTRIBUTION_SCHEMA_VERSION = 1
|
|
|
|
# Default output directory for contributions
|
|
DEFAULT_CONTRIB_DIR = "community_results"
|
|
|
|
|
|
def _model_short_name(model_name: str) -> str:
|
|
"""Extract a filesystem-safe short name from a HuggingFace model ID."""
|
|
# "meta-llama/Llama-2-7b-chat-hf" -> "llama-2-7b-chat-hf"
|
|
name = model_name.split("/")[-1].lower()
|
|
name = re.sub(r"[^a-z0-9\-]", "-", name)
|
|
name = re.sub(r"-+", "-", name).strip("-")
|
|
return name[:60] # cap length
|
|
|
|
|
|
def _config_fingerprint(config: dict[str, Any]) -> str:
|
|
"""Deterministic short hash of the method configuration."""
|
|
canonical = json.dumps(config, sort_keys=True, default=str)
|
|
return hashlib.sha256(canonical.encode()).hexdigest()[:8]
|
|
|
|
|
|
def save_contribution(
|
|
pipeline,
|
|
*,
|
|
model_name: str,
|
|
notes: str = "",
|
|
output_dir: str | Path = DEFAULT_CONTRIB_DIR,
|
|
informed_report=None,
|
|
) -> Path:
|
|
"""Save a contribution record from a completed pipeline run.
|
|
|
|
Unlike telemetry, this:
|
|
- Includes the public model name (for aggregation by model)
|
|
- Saves locally (not sent remotely)
|
|
- Uses a human-readable filename
|
|
- Includes a config fingerprint for deduplication
|
|
- Is always explicit (no silent opt-in)
|
|
|
|
Args:
|
|
pipeline: A completed AbliterationPipeline instance.
|
|
model_name: HuggingFace model ID (e.g., "meta-llama/Llama-2-7b-chat-hf").
|
|
notes: Optional free-text notes about the run.
|
|
output_dir: Directory to save contribution files.
|
|
informed_report: Optional InformedPipelineReport for informed pipeline runs.
|
|
|
|
Returns:
|
|
Path to the saved contribution JSON file.
|
|
"""
|
|
output_dir = Path(output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Build the base telemetry report (reuse existing schema)
|
|
summary = pipeline.handle.summary()
|
|
|
|
config_keys = [
|
|
"n_directions", "norm_preserve", "regularization",
|
|
"refinement_passes", "project_biases", "use_chat_template",
|
|
"use_whitened_svd", "true_iterative_refinement",
|
|
"use_jailbreak_contrast", "layer_adaptive_strength",
|
|
"attention_head_surgery", "safety_neuron_masking",
|
|
"per_expert_directions", "use_sae_features", "invert_refusal",
|
|
"project_embeddings", "embed_regularization",
|
|
"activation_steering", "steering_strength",
|
|
"expert_transplant", "transplant_blend",
|
|
"reflection_strength",
|
|
]
|
|
method_config = {}
|
|
for key in config_keys:
|
|
val = getattr(pipeline, key, None)
|
|
if val is not None:
|
|
method_config[key] = val
|
|
|
|
# Extract analysis insights if informed report is available
|
|
analysis_insights = None
|
|
informed_extras = None
|
|
if informed_report is not None:
|
|
try:
|
|
from obliteratus.telemetry import _extract_analysis_insights
|
|
analysis_insights = _extract_analysis_insights(informed_report)
|
|
informed_extras = {}
|
|
if hasattr(informed_report, "ouroboros_passes"):
|
|
informed_extras["ouroboros_passes"] = informed_report.ouroboros_passes
|
|
if hasattr(informed_report, "final_refusal_rate"):
|
|
informed_extras["final_refusal_rate"] = _safe_float(
|
|
informed_report.final_refusal_rate
|
|
)
|
|
except Exception:
|
|
logger.debug("Failed to extract analysis insights from informed report", exc_info=True)
|
|
|
|
base_report = build_report(
|
|
architecture=summary.get("architecture", "unknown"),
|
|
num_layers=summary.get("num_layers", 0),
|
|
num_heads=summary.get("num_heads", 0),
|
|
hidden_size=summary.get("hidden_size", 0),
|
|
total_params=summary.get("total_params", 0),
|
|
method=pipeline.method,
|
|
method_config=method_config,
|
|
quality_metrics=pipeline._quality_metrics,
|
|
stage_durations=_extract_stage_durations(pipeline),
|
|
strong_layers=pipeline._strong_layers,
|
|
direction_stats=_direction_stats(pipeline),
|
|
excise_details=_extract_excise_details(pipeline),
|
|
prompt_counts=_extract_prompt_counts(pipeline),
|
|
gpu_memory=_get_peak_vram(),
|
|
analysis_insights=analysis_insights,
|
|
informed_extras=informed_extras,
|
|
)
|
|
|
|
# Wrap in community contribution envelope
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
contribution = {
|
|
"contribution_schema_version": CONTRIBUTION_SCHEMA_VERSION,
|
|
"timestamp": timestamp,
|
|
"model_name": model_name,
|
|
"config_fingerprint": _config_fingerprint(method_config),
|
|
"notes": notes,
|
|
"telemetry": base_report,
|
|
}
|
|
|
|
# Generate filename
|
|
short_name = _model_short_name(model_name)
|
|
method = pipeline.method
|
|
ts_short = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
filename = f"{short_name}_{method}_{ts_short}.json"
|
|
filepath = output_dir / filename
|
|
|
|
filepath.write_text(json.dumps(contribution, indent=2, default=str))
|
|
logger.info("Community contribution saved: %s", filepath)
|
|
return filepath
|
|
|
|
|
|
def load_contributions(
|
|
contrib_dir: str | Path = DEFAULT_CONTRIB_DIR,
|
|
) -> list[dict[str, Any]]:
|
|
"""Load all contribution records from a directory.
|
|
|
|
Args:
|
|
contrib_dir: Directory containing contribution JSON files.
|
|
|
|
Returns:
|
|
List of parsed contribution records, sorted by timestamp.
|
|
"""
|
|
contrib_dir = Path(contrib_dir)
|
|
if not contrib_dir.exists():
|
|
return []
|
|
|
|
records = []
|
|
for path in sorted(contrib_dir.glob("*.json")):
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
if "contribution_schema_version" in data:
|
|
data["_source_file"] = str(path)
|
|
records.append(data)
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.warning("Skipping invalid contribution file %s: %s", path, e)
|
|
|
|
records.sort(key=lambda r: r.get("timestamp", ""))
|
|
return records
|
|
|
|
|
|
def aggregate_results(
|
|
records: list[dict[str, Any]],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Aggregate contribution records into per-model, per-method summaries.
|
|
|
|
Groups results by (model_name, method) and computes summary statistics
|
|
for key metrics (refusal_rate, perplexity, coherence).
|
|
|
|
Returns:
|
|
Nested dict: {model_name: {method: {metric: {mean, std, n, values}}}}
|
|
"""
|
|
import statistics
|
|
|
|
groups: dict[tuple[str, str], list[dict]] = {}
|
|
|
|
for record in records:
|
|
model = record.get("model_name", "unknown")
|
|
telemetry = record.get("telemetry", {})
|
|
method = telemetry.get("method", "unknown")
|
|
metrics = telemetry.get("quality_metrics", {})
|
|
|
|
key = (model, method)
|
|
if key not in groups:
|
|
groups[key] = []
|
|
groups[key].append(metrics)
|
|
|
|
results: dict[str, dict[str, Any]] = {}
|
|
for (model, method), metric_list in groups.items():
|
|
if model not in results:
|
|
results[model] = {}
|
|
|
|
summary: dict[str, Any] = {"n_runs": len(metric_list)}
|
|
|
|
for metric_name in ["refusal_rate", "perplexity", "coherence"]:
|
|
values = [
|
|
m[metric_name]
|
|
for m in metric_list
|
|
if metric_name in m and m[metric_name] is not None
|
|
]
|
|
if values:
|
|
summary[metric_name] = {
|
|
"mean": round(statistics.mean(values), 4),
|
|
"std": round(statistics.stdev(values), 4) if len(values) > 1 else 0.0,
|
|
"n": len(values),
|
|
"min": round(min(values), 4),
|
|
"max": round(max(values), 4),
|
|
}
|
|
|
|
results[model][method] = summary
|
|
|
|
return results
|
|
|
|
|
|
def generate_latex_table(
|
|
aggregated: dict[str, dict[str, Any]],
|
|
methods: list[str] | None = None,
|
|
metric: str = "refusal_rate",
|
|
) -> str:
|
|
"""Generate a LaTeX table from aggregated community results.
|
|
|
|
Args:
|
|
aggregated: Output of aggregate_results().
|
|
methods: Methods to include (default: all found).
|
|
metric: Which metric to display (default: refusal_rate).
|
|
|
|
Returns:
|
|
LaTeX table source string.
|
|
"""
|
|
if methods is None:
|
|
all_methods: set[str] = set()
|
|
for model_data in aggregated.values():
|
|
all_methods.update(model_data.keys())
|
|
methods = sorted(all_methods)
|
|
|
|
# Build header
|
|
method_cols = " & ".join(f"\\textbf{{{m}}}" for m in methods)
|
|
header = f"\\textbf{{Model}} & {method_cols} \\\\"
|
|
|
|
lines = [
|
|
"\\begin{tabular}{@{}l" + "c" * len(methods) + "@{}}",
|
|
"\\toprule",
|
|
header,
|
|
"\\midrule",
|
|
]
|
|
|
|
for model in sorted(aggregated.keys()):
|
|
model_data = aggregated[model]
|
|
short = model.split("/")[-1] if "/" in model else model
|
|
|
|
cells = []
|
|
for method in methods:
|
|
if method in model_data and metric in model_data[method]:
|
|
stats = model_data[method][metric]
|
|
mean = stats["mean"]
|
|
n = stats["n"]
|
|
if stats["std"] > 0 and n > 1:
|
|
cells.append(f"{mean:.1f}$\\pm${stats['std']:.1f} ({n})")
|
|
else:
|
|
cells.append(f"{mean:.1f} ({n})")
|
|
else:
|
|
cells.append("---")
|
|
|
|
row = f"{short} & " + " & ".join(cells) + " \\\\"
|
|
lines.append(row)
|
|
|
|
lines.extend(["\\bottomrule", "\\end{tabular}"])
|
|
return "\n".join(lines)
|