diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bea67b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +Architecture and Contribution Guide for AI Agents + +Purpose +This repository contains Phase0 primitives for SoundGraph Studio: content-addressed audio modules, patch graphs with Merkle roots, and provenance events. It's intentionally small and well-tested so subsequent agents can expand to delta-sync, DAW adapters, and a governance ledger. + +Tech stack +- Language: Python 3.8+ +- Crypto: cryptography (ed25519 signatures) +- Testing: pytest +- Packaging: setuptools/pyproject.toml + +Repository layout +- pyproject.toml - build metadata and dependencies +- setup.cfg - setuptools configuration +- README.md - user-facing documentation +- AGENTS.md - this file +- test.sh - run tests and build +- idea44_soundgraph_studio_verifiable/ - Python package code +- tests/ - pytest tests + +Agent rules +1. Always run the test suite after changes: `./test.sh`. +2. Avoid making broad refactors unless requested; prefer small, well-scoped patches. +3. When adding dependencies, update pyproject.toml and setup.cfg accordingly. +4. Do NOT create `READY_TO_PUBLISH` unless the repository fully implements the original idea and all tests pass (the orchestrator enforces this). +5. If you need to run commands, use the provided tooling and keep changes minimal. + +Development notes +- The package name must remain `idea44_soundgraph_studio_verifiable`. +- Packaging must build successfully with `python3 -m build`. +- Tests must be present and passing; any added functionality should include at least one test. diff --git a/README.md b/README.md index 7002e7f..5dbbf95 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,24 @@ -# idea44-soundgraph-studio-verifiable +# SoundGraph Studio — Phase0 primitives -Source logic for Idea #44 \ No newline at end of file +This repository contains Phase0 primitives for SoundGraph Studio: a content-addressed representation of AudioModule objects, an AudioPatch structure with a Merkle-based root for provable inclusion, and simple provenance events with cryptographic signatures. + +What is included +- A small Python package `idea44_soundgraph_studio_verifiable` implementing: + - AudioModule: content-addressed modules (SHA-256 over canonical JSON) + - AudioPatch: nodes referencing module CIDs and edges; Merkle root over nodes+edges + - ProvenanceEvent: lightweight signed events (ed25519) for tamper-evident audit trails +- Tests (pytest) that validate CID reproducibility, Merkle inclusion proofs, and signature verification +- Packaging (pyproject.toml + setup.cfg) and a `test.sh` that runs the test suite and verifies build + +How to run tests +1. Make sure you have Python 3.8+ and `python3 -m pip` available. +2. Run: + +```bash +./test.sh +``` + +This will install the package in editable mode, run pytest, and run `python3 -m build` to verify packaging. + +Notes +- This is intentionally focused on Phase0 local graph primitives — a solid foundation for future delta-sync, adapters, and governance ledger work. diff --git a/idea44_soundgraph_studio_verifiable/__init__.py b/idea44_soundgraph_studio_verifiable/__init__.py new file mode 100644 index 0000000..ce0f928 --- /dev/null +++ b/idea44_soundgraph_studio_verifiable/__init__.py @@ -0,0 +1,9 @@ +"""idea44_soundgraph_studio_verifiable + +Phase0 primitives: content-addressed AudioModule, AudioPatch with Merkle root, +and ProvenanceEvent signing/verification. +""" + +from .core import AudioModule, AudioPatch, ProvenanceEvent + +__all__ = ["AudioModule", "AudioPatch", "ProvenanceEvent"] diff --git a/idea44_soundgraph_studio_verifiable/core.py b/idea44_soundgraph_studio_verifiable/core.py new file mode 100644 index 0000000..9efbdbe --- /dev/null +++ b/idea44_soundgraph_studio_verifiable/core.py @@ -0,0 +1,169 @@ +"""Core primitives: AudioModule, AudioPatch, ProvenanceEvent + +Small, well-scoped implementations suitable for Phase0 tests. +""" +from __future__ import annotations + +import json +import hashlib +import time +import uuid +from dataclasses import dataclass, field, asdict +from typing import List, Dict, Tuple + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey +from cryptography.hazmat.primitives import serialization + + +def canonical_json(data) -> bytes: + # deterministic JSON encoding + return json.dumps(data, separators=(",", ":"), sort_keys=True, ensure_ascii=False).encode("utf-8") + + +def sha256(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + + +@dataclass +class AudioModule: + id: str + type: str + params: Dict + version: str = "0.1" + license: str = "" + + def to_dict(self) -> Dict: + return asdict(self) + + def cid(self) -> str: + """Content ID: SHA-256 over canonical JSON of the module.""" + data = self.to_dict() + return sha256(canonical_json(data)) + + @staticmethod + def make(type: str, params: Dict, version: str = "0.1", license: str = "") -> "AudioModule": + return AudioModule(id=str(uuid.uuid4()), type=type, params=params, version=version, license=license) + + +def merkle_tree_root(leaves: List[bytes]) -> str: + """Simple Merkle tree root (binary) over raw leaf bytes. Returns hex digest.""" + if not leaves: + return sha256(b"") + + level = [hashlib.sha256(l).digest() for l in leaves] + + while len(level) > 1: + next_level = [] + for i in range(0, len(level), 2): + left = level[i] + right = level[i + 1] if i + 1 < len(level) else left + next_level.append(hashlib.sha256(left + right).digest()) + level = next_level + + return level[0].hex() + + +def merkle_proof(leaves: List[bytes], index: int) -> List[Tuple[str, str]]: + """Return a list of (sibling_hash(hex), direction) where direction is 'L' or 'R'.""" + if index < 0 or index >= len(leaves): + raise IndexError("leaf index") + + # compute tree levels as bytes + levels = [ [hashlib.sha256(l).digest() for l in leaves] ] + + while len(levels[-1]) > 1: + cur = levels[-1] + nxt = [] + for i in range(0, len(cur), 2): + left = cur[i] + right = cur[i+1] if i+1 < len(cur) else left + nxt.append(hashlib.sha256(left + right).digest()) + levels.append(nxt) + + proof = [] + idx = index + for level in levels[:-1]: + sibling_idx = idx ^ 1 + sibling = level[sibling_idx] if sibling_idx < len(level) else level[idx] + direction = 'L' if sibling_idx < idx else 'R' + proof.append((sibling.hex(), direction)) + idx = idx // 2 + + return proof + + +def verify_merkle_proof(leaf: bytes, proof: List[Tuple[str, str]], root_hex: str) -> bool: + cur = hashlib.sha256(leaf).digest() + for sibling_hex, direction in proof: + sibling = bytes.fromhex(sibling_hex) + if direction == 'L': + cur = hashlib.sha256(sibling + cur).digest() + else: + cur = hashlib.sha256(cur + sibling).digest() + return cur.hex() == root_hex + + +@dataclass +class AudioPatch: + nodes: List[Dict] = field(default_factory=list) # each node: {id, module_cid, params} + edges: List[Tuple[str, str]] = field(default_factory=list) # list of (from_node_id, to_node_id) + metadata: Dict = field(default_factory=dict) + + def to_dict(self) -> Dict: + return { + "nodes": self.nodes, + "edges": self.edges, + "metadata": self.metadata, + } + + def merkle_root(self) -> str: + # leaves are canonical JSON of each node (sorted by node id) and each edge + node_items = sorted(self.nodes, key=lambda n: n.get("id")) + node_leaves = [canonical_json(n) for n in node_items] + edge_items = sorted([{"from": f, "to": t} for f,t in self.edges], key=lambda e: (e['from'], e['to'])) + edge_leaves = [canonical_json(e) for e in edge_items] + leaves = node_leaves + edge_leaves + return merkle_tree_root(leaves) + + def inclusion_proof_for_module_cid(self, module_cid: str) -> Tuple[List[Tuple[str,str]], bytes]: + # find the node(s) that reference this module_cid and return proof for first occurrence + node_items = sorted(self.nodes, key=lambda n: n.get("id")) + node_leaves = [canonical_json(n) for n in node_items] + edge_items = sorted([{"from": f, "to": t} for f,t in self.edges], key=lambda e: (e['from'], e['to'])) + edge_leaves = [canonical_json(e) for e in edge_items] + leaves = node_leaves + edge_leaves + + for idx, n in enumerate(node_items): + if n.get("module_cid") == module_cid: + proof = merkle_proof(leaves, idx) + return proof, node_leaves[idx] + + raise KeyError("module_cid not referenced in patch") + + +@dataclass +class ProvenanceEvent: + patch_id: str + actor: str + timestamp: float + op_hash: str + signature: bytes = b"" + + def to_dict(self) -> Dict: + return {"patch_id": self.patch_id, "actor": self.actor, "timestamp": self.timestamp, "op_hash": self.op_hash} + + def sign(self, private_key: Ed25519PrivateKey) -> None: + self.signature = private_key.sign(canonical_json(self.to_dict())) + + def verify(self, public_key: Ed25519PublicKey) -> bool: + try: + public_key.verify(self.signature, canonical_json(self.to_dict())) + return True + except Exception: + return False + + +def generate_ed25519_keypair() -> Tuple[Ed25519PrivateKey, Ed25519PublicKey]: + sk = Ed25519PrivateKey.generate() + pk = sk.public_key() + return sk, pk diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1c45d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["setuptools>=61.0","wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea44_soundgraph_studio_verifiable" +version = "0.0.1" +description = "SoundGraph Studio primitives: Merkle-DAG patches, content-addressed modules, and provenance events (MVP Phase0)" +readme = "README.md" +authors = [ { name = "OpenCode" } ] +requires-python = ">=3.8" +dependencies = ["cryptography>=39.0.0"] diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..a5f9de3 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,13 @@ +[metadata] +name = idea44_soundgraph_studio_verifiable +version = 0.0.1 +description = SoundGraph Studio primitives: Merkle-DAG patches, content-addressed modules, and provenance events (MVP Phase0) +long_description = file: README.md +long_description_content_type = text/markdown +author = OpenCode + +[options] +packages = find: +install_requires = + cryptography>=39.0.0 +python_requires = >=3.8 diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..fa74791 --- /dev/null +++ b/test.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Installing package in editable mode..." +python3 -m pip install -e . + +echo "Running tests..." +pytest -q + +echo "Building package..." +python3 -m build + +echo "All done" diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..144caa7 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,35 @@ +import json +from idea44_soundgraph_studio_verifiable.core import AudioModule, AudioPatch, generate_ed25519_keypair, ProvenanceEvent + + +def test_audio_module_cid_is_stable(): + m = AudioModule.make("oscillator", {"freq": 440, "wave": "sine"}, version="0.1") + cid1 = m.cid() + # recompute from dict to ensure canonicalization is stable + cid2 = AudioModule(id=m.id, type=m.type, params=m.params, version=m.version, license=m.license).cid() + assert cid1 == cid2 + + +def test_patch_merkle_and_inclusion_proof(): + m1 = AudioModule.make("oscillator", {"freq": 220}) + m2 = AudioModule.make("filter", {"type": "lowpass", "cutoff": 1000}) + + node1 = {"id": "n1", "module_cid": m1.cid(), "params": {}} + node2 = {"id": "n2", "module_cid": m2.cid(), "params": {}} + + patch = AudioPatch(nodes=[node1, node2], edges=[("n1","n2")]) + root = patch.merkle_root() + assert isinstance(root, str) and len(root) == 64 + + proof, leaf = patch.inclusion_proof_for_module_cid(m1.cid()) + # verify proof using the exported verification helper + from idea44_soundgraph_studio_verifiable.core import verify_merkle_proof + + assert verify_merkle_proof(leaf, proof, root) + + +def test_provenance_event_sign_and_verify(): + sk, pk = generate_ed25519_keypair() + ev = ProvenanceEvent(patch_id="patch-1", actor="alice", timestamp=123456.0, op_hash="deadbeef") + ev.sign(sk) + assert ev.verify(pk)