Skip to content

Repository files navigation

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:

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
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:

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:

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:

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

{{image}}
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 — 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 — composition and the identity model (typed parts, structure_hash / instance_hash, branching, redaction).
  • Storage — author prompts as .prompt folders and load them by name (unit.yaml / *.assembly.yaml, params, binary holes, versions, imports).
  • Evals — targeting by tags, coverage, datasets, scoring.
  • examples/ — runnable scripts: zero-setup library demos (core/, evals/) and real-model integrations (integrations/).

Install

Requires Python 3.11+. Dual-licensed: MIT or Apache-2.0, at your option.

pip install peisinoe             # ALL of Peisinoe (core, evals, storage) — zero dependencies
pip install "peisinoe[storage]"  # same code + PyYAML, the loader's default parser
                                 # (or skip the extra and pass your own with load(..., parse=...))

For development, from a checkout:

python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev,storage]"
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).

About

Prompt composition for Python with reusable typed parts, explicit variants, deterministic identity, file-based authoring, and structure-aware eval coverage.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages