From 170a2b6d37d013c2e28a9369bf6c26077d9eb45d Mon Sep 17 00:00:00 2001 From: agent-14fd4b738639d573 Date: Fri, 24 Apr 2026 17:59:59 +0200 Subject: [PATCH] build(agent): melter#14fd4b iteration --- .gitignore | 21 +++++++ AGENTS.md | 38 ++++++++++++ README.md | 31 +++++++++- pyproject.toml | 17 ++++++ .../__init__.py | 17 ++++++ .../analytics/__init__.py | 3 + .../analytics/harness.py | 16 +++++ .../dsl.py | 58 +++++++++++++++++++ .../modules/__init__.py | 3 + .../modules/portfolio.py | 37 ++++++++++++ .../modules/risk_literacy.py | 41 +++++++++++++ .../simulation/__init__.py | 3 + .../simulation/market.py | 16 +++++ .../sync/__init__.py | 3 + .../sync/ledger.py | 33 +++++++++++ test.sh | 11 ++++ tests/test_dsl.py | 26 +++++++++ tests/test_simulation_and_ledger.py | 15 +++++ 18 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 src/idea128_investlearn_studio_verifiable/__init__.py create mode 100644 src/idea128_investlearn_studio_verifiable/analytics/__init__.py create mode 100644 src/idea128_investlearn_studio_verifiable/analytics/harness.py create mode 100644 src/idea128_investlearn_studio_verifiable/dsl.py create mode 100644 src/idea128_investlearn_studio_verifiable/modules/__init__.py create mode 100644 src/idea128_investlearn_studio_verifiable/modules/portfolio.py create mode 100644 src/idea128_investlearn_studio_verifiable/modules/risk_literacy.py create mode 100644 src/idea128_investlearn_studio_verifiable/simulation/__init__.py create mode 100644 src/idea128_investlearn_studio_verifiable/simulation/market.py create mode 100644 src/idea128_investlearn_studio_verifiable/sync/__init__.py create mode 100644 src/idea128_investlearn_studio_verifiable/sync/ledger.py create mode 100755 test.sh create mode 100644 tests/test_dsl.py create mode 100644 tests/test_simulation_and_ledger.py 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..68a3731 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +AGENTS.md +======== + +Repository purpose +------------------ + +This repo hosts the InvestLearn Studio prototype: a verifiable, offline-first investment education & practice engine. The code here is intentionally small but structured to be extended by additional agents in the swarm. + +Architecture & Tech Stack +------------------------ + +- Language: Python 3.8+ +- Packaging: pyproject.toml (setuptools backend) +- Key packages: pandas (analytics), pytest (tests) +- Source layout: `src/idea128_investlearn_studio_verifiable` + +Key components +-------------- + +- dsl.py: simple DSL parser for learner objectives +- modules/: learning modules (risk_literacy, portfolio) +- simulation/: market simulation engine +- sync/: lightweight cryptographic ledger for attestations +- analytics/: toy analytics harness using pandas + +Testing & Commands +------------------ + +Run the full verification and packaging pipeline locally: + +``` +./test.sh +``` + +What agents must follow +----------------------- + +1. Read this file before changing packaging or tests. 2. Do not remove `test.sh` or change its behavior without updating the CI maintainers. 3. When adding dependencies, update `pyproject.toml` and ensure tests still run quickly. 4. Keep changes minimal and localized. 5. If you're adding new data files, update MANIFEST or package_data accordingly. diff --git a/README.md b/README.md index 485bf63..7676a1a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,30 @@ -# idea128-investlearn-studio-verifiable +InvestLearn Studio (prototype) +================================ -Source logic for Idea #128 \ No newline at end of file +InvestLearn Studio is an offline-first, verifiable learning engine for investment education. + +This repository contains a focused, well-tested chunk of functionality for the MVP: + +- A small DSL parser to declare learning objectives and preferences +- Two learning modules: risk literacy and basic portfolio concepts +- A deterministic market simulation engine (seeded RNG) +- A tiny verifiable ledger to attest to completed modules and quiz results +- A toy analytics harness using pandas + +Usage +----- + +Run tests and build the package: + +``` +./test.sh +``` + +Project structure +----------------- + +- src/idea128_investlearn_studio_verifiable: core package +- tests/: pytest tests +- AGENTS.md: contribution and architecture guidance for agents + +License: MIT (prototype) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fd2464a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea128_investlearn_studio_verifiable" +version = "0.1.0" +description = "InvestLearn Studio: Verifiable, Offline-First Investment Education & Practice Engine (prototype)" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "pandas>=1.3.0", + "pytest>=6.0", +] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/idea128_investlearn_studio_verifiable/__init__.py b/src/idea128_investlearn_studio_verifiable/__init__.py new file mode 100644 index 0000000..7f0bacb --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/__init__.py @@ -0,0 +1,17 @@ +"""InvestLearn Studio core package + +This package contains a minimal prototype of the DSL, learning modules, +simulation engine, ledger, and analytics harness. +""" + +__all__ = [ + "dsl", + "modules", + "simulation", + "sync", + "analytics", +] + +from . import dsl, modules, simulation, sync, analytics + +__version__ = "0.1.0" diff --git a/src/idea128_investlearn_studio_verifiable/analytics/__init__.py b/src/idea128_investlearn_studio_verifiable/analytics/__init__.py new file mode 100644 index 0000000..60c96a2 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/analytics/__init__.py @@ -0,0 +1,3 @@ +from .harness import compute_progress_frame + +__all__ = ["compute_progress_frame"] diff --git a/src/idea128_investlearn_studio_verifiable/analytics/harness.py b/src/idea128_investlearn_studio_verifiable/analytics/harness.py new file mode 100644 index 0000000..13bbb43 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/analytics/harness.py @@ -0,0 +1,16 @@ +"""Toy analytics harness using pandas. + +compute_progress_frame takes a list of module instances and returns a +pandas.DataFrame summarizing progress per learner/module. +""" +from typing import List, Dict, Any +import pandas as pd + + +def compute_progress_frame(instances: List[Dict[str, Any]]) -> pd.DataFrame: + # instances: list of dicts { learner_id, module_id, progress } + df = pd.DataFrame(instances) + if df.empty: + return pd.DataFrame(columns=["learner_id", "module_id", "progress"]) + grouped = df.groupby(["learner_id", "module_id"]).agg({"progress": "mean"}).reset_index() + return grouped diff --git a/src/idea128_investlearn_studio_verifiable/dsl.py b/src/idea128_investlearn_studio_verifiable/dsl.py new file mode 100644 index 0000000..f060c47 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/dsl.py @@ -0,0 +1,58 @@ +"""Simple DSL parser for declaring learning objectives. + +DSL format (very small subset): + +objective : + title = "..." + risk = low|medium|high + steps = ["step1", "step2"] + +This parser produces a dict with the declared fields. +""" +from typing import Dict, Any, List +import re + + +class DSLParseError(Exception): + pass + + +def parse(dsl_text: str) -> Dict[str, Any]: + lines = [l.strip() for l in dsl_text.strip().splitlines() if l.strip()] + if not lines: + raise DSLParseError("empty DSL") + + header = lines[0] + m = re.match(r"objective\s+(\w+):", header) + if not m: + raise DSLParseError("expected 'objective :' header") + obj_id = m.group(1) + + result: Dict[str, Any] = {"id": obj_id} + + for line in lines[1:]: + if line.startswith("title"): + m = re.match(r'title\s*=\s*"([^"]+)"', line) + if not m: + raise DSLParseError("malformed title") + result["title"] = m.group(1) + elif line.startswith("risk"): + m = re.match(r"risk\s*=\s*(low|medium|high)", line) + if not m: + raise DSLParseError("malformed risk") + result["risk"] = m.group(1) + elif line.startswith("steps"): + m = re.match(r"steps\s*=\s*\[(.*)\]", line) + if not m: + raise DSLParseError("malformed steps") + inner = m.group(1).strip() + if not inner: + result["steps"] = [] + else: + parts = [p.strip().strip('"') for p in inner.split(",")] + result["steps"] = parts + else: + # ignore unknown lines for forward compat + continue + + return result diff --git a/src/idea128_investlearn_studio_verifiable/modules/__init__.py b/src/idea128_investlearn_studio_verifiable/modules/__init__.py new file mode 100644 index 0000000..1658381 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/modules/__init__.py @@ -0,0 +1,3 @@ +from . import risk_literacy, portfolio + +__all__ = ["risk_literacy", "portfolio"] diff --git a/src/idea128_investlearn_studio_verifiable/modules/portfolio.py b/src/idea128_investlearn_studio_verifiable/modules/portfolio.py new file mode 100644 index 0000000..b2527a7 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/modules/portfolio.py @@ -0,0 +1,37 @@ +"""Basic portfolio concepts learning module (toy implementation) +""" +from dataclasses import dataclass, field +from typing import List, Dict + + +@dataclass +class LearningModuleInstance: + id: str + title: str + steps: List[str] + completed_steps: List[str] = field(default_factory=list) + + def progress(self) -> float: + if not self.steps: + return 0.0 + return len(self.completed_steps) / len(self.steps) + + def complete_step(self, step: str): + if step in self.steps and step not in self.completed_steps: + self.completed_steps.append(step) + + +class LearningModule: + def __init__(self, id: str, title: str, steps: List[str]): + self.id = id + self.title = title + self.steps = steps + + def instantiate(self) -> LearningModuleInstance: + return LearningModuleInstance(id=self.id, title=self.title, steps=list(self.steps)) + + def quiz(self) -> Dict[str, str]: + return { + "q1": "What is an asset allocation?", + "q2": "Name two broad asset classes.", + } diff --git a/src/idea128_investlearn_studio_verifiable/modules/risk_literacy.py b/src/idea128_investlearn_studio_verifiable/modules/risk_literacy.py new file mode 100644 index 0000000..ce5f8d4 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/modules/risk_literacy.py @@ -0,0 +1,41 @@ +"""Risk literacy learning module (toy implementation) + +Exposes a LearningModule class which can be instantiated to produce +LearningModuleInstance objects that track progress and produce a tiny quiz. +""" +from dataclasses import dataclass, field +from typing import List, Dict + + +@dataclass +class LearningModuleInstance: + id: str + title: str + steps: List[str] + completed_steps: List[str] = field(default_factory=list) + + def progress(self) -> float: + if not self.steps: + return 0.0 + return len(self.completed_steps) / len(self.steps) + + def complete_step(self, step: str): + if step in self.steps and step not in self.completed_steps: + self.completed_steps.append(step) + + +class LearningModule: + def __init__(self, id: str, title: str, steps: List[str]): + self.id = id + self.title = title + self.steps = steps + + def instantiate(self) -> LearningModuleInstance: + return LearningModuleInstance(id=self.id, title=self.title, steps=list(self.steps)) + + def quiz(self) -> Dict[str, str]: + # Tiny deterministic quiz + return { + "q1": "What is diversification?", + "q2": "Does higher expected return always mean lower risk?", + } diff --git a/src/idea128_investlearn_studio_verifiable/simulation/__init__.py b/src/idea128_investlearn_studio_verifiable/simulation/__init__.py new file mode 100644 index 0000000..44746d1 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/simulation/__init__.py @@ -0,0 +1,3 @@ +from .market import simulate_price_series + +__all__ = ["simulate_price_series"] diff --git a/src/idea128_investlearn_studio_verifiable/simulation/market.py b/src/idea128_investlearn_studio_verifiable/simulation/market.py new file mode 100644 index 0000000..a25753d --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/simulation/market.py @@ -0,0 +1,16 @@ +"""Tiny deterministic market simulation engine. + +simulate_price_series(seed, n_steps, start_price) -> list[float] +""" +from typing import List +import random + + +def simulate_price_series(seed: int, n_steps: int = 10, start_price: float = 100.0) -> List[float]: + rnd = random.Random(seed) + prices = [float(start_price)] + for _ in range(n_steps - 1): + # small random walk multiplicative returns + ret = rnd.uniform(-0.05, 0.05) + prices.append(prices[-1] * (1 + ret)) + return prices diff --git a/src/idea128_investlearn_studio_verifiable/sync/__init__.py b/src/idea128_investlearn_studio_verifiable/sync/__init__.py new file mode 100644 index 0000000..6759bae --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/sync/__init__.py @@ -0,0 +1,3 @@ +from .ledger import Ledger + +__all__ = ["Ledger"] diff --git a/src/idea128_investlearn_studio_verifiable/sync/ledger.py b/src/idea128_investlearn_studio_verifiable/sync/ledger.py new file mode 100644 index 0000000..68c9e26 --- /dev/null +++ b/src/idea128_investlearn_studio_verifiable/sync/ledger.py @@ -0,0 +1,33 @@ +"""Lightweight append-only ledger with SHA256 chaining for attestations. + +This is intentionally tiny: each entry contains a payload and a chain hash +linking to the previous entry so the ledger is tamper-evident in a simple way. +""" +import hashlib +import json +from typing import List, Dict, Any, Optional + + +class Ledger: + def __init__(self): + self.entries: List[Dict[str, Any]] = [] + + def _chain_hash(self, prev_hash: Optional[str], payload: Dict[str, Any]) -> str: + data = json.dumps({"prev": prev_hash, "payload": payload}, sort_keys=True) + return hashlib.sha256(data.encode("utf-8")).hexdigest() + + def append(self, payload: Dict[str, Any]) -> Dict[str, Any]: + prev = self.entries[-1]["hash"] if self.entries else None + h = self._chain_hash(prev, payload) + entry = {"payload": payload, "prev": prev, "hash": h} + self.entries.append(entry) + return entry + + def verify(self) -> bool: + prev = None + for e in self.entries: + expected = self._chain_hash(prev, e["payload"]) # type: ignore[arg-type] + if expected != e["hash"]: + return False + prev = e["hash"] + return True diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..1ab7da1 --- /dev/null +++ b/test.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "Running tests..." +# Ensure src/ is on PYTHONPATH so tests can import the package in-place +PYTHONPATH="src" pytest -q + +echo "Building package..." +python3 -m build + +echo "All done." diff --git a/tests/test_dsl.py b/tests/test_dsl.py new file mode 100644 index 0000000..eeb0c2c --- /dev/null +++ b/tests/test_dsl.py @@ -0,0 +1,26 @@ +from idea128_investlearn_studio_verifiable import dsl + + +def test_parse_basic_objective(): + text = ''' + objective o1: + title = "Intro to Risk" + risk = low + steps = ["what is risk", "diversification"] + ''' + parsed = dsl.parse(text) + assert parsed["id"] == "o1" + assert parsed["title"] == "Intro to Risk" + assert parsed["risk"] == "low" + assert parsed["steps"] == ["what is risk", "diversification"] + + +def test_parse_empty_steps(): + text = ''' + objective x: + title = "Empty" + risk = medium + steps = [] + ''' + parsed = dsl.parse(text) + assert parsed["steps"] == [] diff --git a/tests/test_simulation_and_ledger.py b/tests/test_simulation_and_ledger.py new file mode 100644 index 0000000..eb30857 --- /dev/null +++ b/tests/test_simulation_and_ledger.py @@ -0,0 +1,15 @@ +from idea128_investlearn_studio_verifiable import simulation, sync + + +def test_simulation_deterministic(): + a = simulation.simulate_price_series(seed=42, n_steps=5, start_price=100.0) + b = simulation.simulate_price_series(seed=42, n_steps=5, start_price=100.0) + assert a == b + assert len(a) == 5 + + +def test_ledger_append_and_verify(): + ledger = sync.Ledger() + ledger.append({"event": "module_completed", "module": "risk"}) + ledger.append({"event": "quiz_passed", "module": "portfolio", "score": 0.8}) + assert ledger.verify()