Metadata-Version: 2.4
Name: peisinoe
Version: 0.1.0
Summary: Deterministic, content-addressed prompt composition — the Peisinoe core (Python reference implementation)
Author: Ashutosh Mahala
License: MIT OR Apache-2.0
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Keywords: composition,content-addressed,llm,prompt,versioning
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: storage
Requires-Dist: pyyaml>=6.0; extra == 'storage'
Description-Content-Type: text/markdown

# Peisinoe

**Prompt composition with explicit variants and deterministic identity.**

Peisinoe is a Python library for building prompts from reusable, typed parts —
text and binary alike. Parts can be combined, selected, and ordered
declaratively, so feature flags, A/B variants, and other prompt experiments
remain part of the prompt definition rather than being scattered through
application code.

Prompts can be authored in Markdown and YAML packages and loaded by name, or
constructed directly in Python. Resolving a prompt binds its inputs and
produces model-ready messages together with deterministic identities for the
selected structure and the exact filled instance. Recorded alongside a model's
output, they identify exactly which composition produced it.

The eval layer uses the same structure to enumerate reachable variants, target
them, and report coverage. Model execution stays in the application through a
small bring-your-own `Model` interface; the core has zero runtime dependencies.

## Why it exists

Prompt iteration often changes both content and the logic that selects or
combines it. When that content and logic live inside application code,
experiments can require edits across unrelated code paths, variants are harder
to exercise systematically, and the exact prompt used for a result may be
difficult to reconstruct.

Peisinoe keeps prompt source and composition separate from application flow.
Variants remain explicit, reusable parts can be shared, and each resolution
carries a content-derived identity.

## A taste

Author prompts as files and **load them by name** — application code loads a
package once, then addresses assemblies and units by name:

```text
support.prompt/              # a package (a ".prompt" folder)
├── triage.assembly.yaml       # an assembly: named parts wired to units
├── system/unit.yaml         # a unit: params + sections (Select, Child, …)
└── user_message.md          # a unit: a bare Markdown file *is* a unit
```

```python
from peisinoe_tools.storage import load

pkg = load("support.prompt")                      # point at the folder once
prompt = pkg["triage"]                            # get the assembly by name
prompt.resolve({"tier": "pro", "question": "…"}).materialize()
```

The same structures can be constructed directly in Python:

```python
import peisinoe_core as p

support = p.Unit("support", params=("tier",), sections=(
    p.Static("hi", "Hello!"),
    p.Select("policy", on="tier", cases={
        "free": p.Static("f", "Basic help."),
        "pro":  p.Static("pp", "Priority help.", tags=("safety",)),
    }, default="free"),
))
r = support.resolve({"tier": "pro"})
r.structure_hash   # the variant (values excluded) — your attribution key
r.materialize()    # typed, role-tagged Messages of typed Parts
```

A loaded unit compiles to the same identity as the hand-built one
(`loaded.full_version() == built.full_version()`) — the loader is a compiler.

Target an eval at a variant, then see what you missed:

```python
from peisinoe_tools.evals import EvalSpec, Target, Contains, plan, coverage

spec = EvalSpec("mentions_help", Target(all_of=("safety",)), Contains("help"))
pl = plan(support, [spec])
coverage(pl).branches_uncovered   # ('support > policy=free',)  — the free branch is untested
```

## Beyond text

Prompts aren't only text. A single Markdown file can define a complete
**multimodal** prompt — a scalar hole and a typed binary hole:

```markdown
---
params:
  instruction:
  image:
    type: blob
---
{{instruction}}

{{image}}
```

```python
edit = load("image_edit.prompt").unit("edit")
r = edit.resolve({"instruction": "turn the cat into a tiger",
                  "image": p.Blob("image/png", png_bytes)})
```

A binary hole also takes an **ordered list** (`[img_a, img_b, img_c]` — a
model's "up to N reference images" is one param), and 1-vs-N values is the
*same* `structure_hash`: count, order, and content are instance data.

## Design model

Peisinoe uses the following model:

- **Prompt as a *program*, not a string** — it branches, and the branch space is
  enumerable.
- **Identity is *content-addressed*; names and versions are addressing** —
  identity is derived from the canonical template or resolved representation,
  not assigned by a label. A `version:` label is a mutable human pointer (like
  a git tag); hashes are computed, **never written into files**.
- **Binary is prompt content, not an attachment** — images, PDFs, and external
  refs fill typed holes and participate in prompt identity and redaction;
  `Ref` values also support deferred loading. A runnable image-editing
  integration is included in `examples/integrations/`.
- **Target by *intent*, not *position*** — evals point at tags (what a block is
  *about*), not block names (where it *is*).
- **You ask "what did I *cover*?", not just "did it *pass*?"** — because
  branches are explicit, coverage can report which cases an eval plan
  exercised.
- **Record, don't control** — the eval layer scores what a bring-your-own model
  returns; it never drives the model (so it works unchanged with multimodal
  output, agents, even diffusion models).

## When (not) to use it

**Use it** when you have prompt *variation* worth tracking — variants, tiers, A/B
flags, evals you want to target and cover, or a need to attribute results to an
exact prompt.

**It won't** ship a provider adapter, route to providers, stream, or score by
token log-probabilities (a deliberate boundary — it scores observed output, not
model internals). You bring the `Model`; the eval layer calls it and scores what
it returns.

## Documentation

- **[Tutorial](docs/tutorial.md)** — the whole loop end to end in ~15 minutes:
  build a branching prompt, see its identity, author it on disk, target evals,
  and find the branch you missed.
- **[Core](docs/core.md)** — composition and the identity model (typed parts,
  `structure_hash` / `instance_hash`, branching, redaction).
- **[Storage](docs/storage.md)** — author prompts as `.prompt` folders and load
  them by name (`unit.yaml` / `*.assembly.yaml`, params, binary holes, versions,
  imports).
- **[Evals](docs/evals.md)** — targeting by tags, coverage, datasets, scoring.
- **[examples/](examples/)** — runnable scripts: zero-setup library demos
  (`core/`, `evals/`) and real-model integrations (`integrations/`).

## Install (development)

Requires Python 3.11+. Dual-licensed: [MIT](LICENSE-MIT) or
[Apache-2.0](LICENSE-APACHE), at your option. Not yet on PyPI —
install from a checkout:

```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,storage]"   # ".[storage]" adds the loader's YAML parser (or bring your own)
pytest
ruff check . && mypy peisinoe_core peisinoe_tools
```

Knowledge flows downward only: `peisinoe_core` (zero-dependency composition +
identity), `peisinoe_tools.evals` (the eval layer built on it), and
`peisinoe_tools.storage` (an opt-in loader that compiles `.prompt` folders to the
same core objects). The core stays dependency-free; only `storage` has an optional
extra.

## The name

Peisinoë is one of the Sirens — the singers whose *composed voices* drew sailors
in. A fitting namesake for a library about composing prompts (and, with luck, a
little luring). 🙂

---

*Alpha / reference implementation.* The hashing is locked by golden vectors
(`testvectors/core.json`) for cross-language conformance — regenerate after an
*intentional* hashing change and bump `HASHSPEC`
(`python -m tests.gen_vectors`).
