CANP-WP-0002 T01: input defaults, static and derived
Closes the gap that made optional inputs unusable: rendering rule 5.1(4) made any unresolved placeholder an error while inputs had no `default`, so an input marked `required: false` and referenced from the template failed every render in which the caller omitted it — including the spec's own section 4 example. Section 10 already carried the derivation mechanism (`requirement: generate`, resolution deliberately undefined), so a derived default needed a binding rather than a new concept: the input's default names a declared prompt dependency. Spec: - 5.1 rewritten as "Resolution and rendering". Resolution may be non-deterministic and must report what it derived; rendering is deterministic and must not derive. A tool that handles only supplied values and static defaults is stated to be conforming. - 6.1 (new) covers both declaration forms. Reference form is preferred, with the reason stated — an inline prompt is anonymous, so unversioned, unprovenanced and un-evaluable — and validators should warn when a published package derives inline. - A derived default may declare a static fallback `value`. Without one the input stays unresolved, which is an error; derivation never silently yields empty content. - 4, 10, 18 (rules 11-13), 19 (two new MUST NOTs), 21 updated accordingly. Reference CLI: - New `resolve` verb reporting the origin of every value. - `Resolution` dataclass and `resolve_inputs`; `resolve_values` kept as a wrapper so existing callers are unaffected. - `render` refuses with a specific error naming underivable inputs rather than substituting empty text. - Tests 3 -> 11. Example package lifecycle re-verified end to end. INTENT.md is unchanged: splitting resolve from render preserves success criterion 4 (deterministic rendering) as written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM Assistant: claude-code Assistant-Model: opus Assistant-Process: 388925@bnt-lap001 Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
parent
0caf065544
commit
169db25d25
6 changed files with 600 additions and 36 deletions
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
This is intentionally a **small reference implementation**, not the intended final architecture.
|
||||
|
||||
It demonstrates six verbs:
|
||||
It demonstrates seven verbs:
|
||||
|
||||
```text
|
||||
add PATH
|
||||
search QUERY
|
||||
show ID
|
||||
resolve ID --set key=value
|
||||
render ID --set key=value
|
||||
install ID [--version VERSION]
|
||||
publish PATH
|
||||
|
|
@ -15,6 +16,11 @@ publish PATH
|
|||
|
||||
The implementation uses a local catalog plus a filesystem registry and performs no model calls.
|
||||
|
||||
`resolve` and `render` are separate because the specification separates them
|
||||
(§ 5.1): resolution decides each value and may be non-deterministic, rendering
|
||||
substitutes and always is. `resolve` prints where every value came from —
|
||||
supplied, default, or fallback — before any prompt is produced.
|
||||
|
||||
## Stores
|
||||
|
||||
Default locations:
|
||||
|
|
@ -44,6 +50,10 @@ For example:
|
|||
- Published versions are immutable by default.
|
||||
- `install` copies from registry to catalog.
|
||||
- `add` copies a package directly to catalog.
|
||||
- `search`, `show`, and `render` operate on catalog packages.
|
||||
- `search`, `show`, `resolve`, and `render` operate on catalog packages.
|
||||
- Static input defaults are applied; **derived** defaults (§ 6.1) are not. This
|
||||
tool never calls a model, so a derived default is satisfied only by its
|
||||
static fallback `value`. Without one, `resolve` reports the input as
|
||||
unresolved and `render` refuses rather than substituting empty text.
|
||||
|
||||
Use this implementation to challenge the format. Replace it once real usage reveals the right architecture.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import os
|
|||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
|
@ -92,6 +93,78 @@ def declared_names(manifest: dict[str, Any]) -> set[str]:
|
|||
return names
|
||||
|
||||
|
||||
def input_default_kind(item: dict[str, Any]) -> str:
|
||||
"""Classify an input's default as 'none', 'static', or 'derived' (§ 6.1)."""
|
||||
if "default" not in item:
|
||||
return "none"
|
||||
default = item["default"]
|
||||
if isinstance(default, dict) and "derive" in default:
|
||||
return "derived"
|
||||
return "static"
|
||||
|
||||
|
||||
def prompt_dependency_ids(manifest: dict[str, Any]) -> set[str]:
|
||||
dependencies = manifest.get("dependencies") or {}
|
||||
if not isinstance(dependencies, dict):
|
||||
raise CannedPromptError("dependencies must be a mapping")
|
||||
prompts = dependencies.get("prompts") or []
|
||||
if not isinstance(prompts, list):
|
||||
raise CannedPromptError("dependencies.prompts must be a list")
|
||||
ids: set[str] = set()
|
||||
for entry in prompts:
|
||||
if isinstance(entry, str):
|
||||
ids.add(entry)
|
||||
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
|
||||
ids.add(entry["id"])
|
||||
else:
|
||||
raise CannedPromptError(
|
||||
"each prompt dependency must be an id string or a mapping with a string id"
|
||||
)
|
||||
return ids
|
||||
|
||||
|
||||
def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> None:
|
||||
"""Validation rules 11-13 of § 18."""
|
||||
kind = input_default_kind(item)
|
||||
if kind == "none":
|
||||
return
|
||||
name = item["name"]
|
||||
if item.get("required", False):
|
||||
raise CannedPromptError(
|
||||
f"input {name!r} declares a default and required: true; a required "
|
||||
"input is always supplied, so the default could never apply"
|
||||
)
|
||||
if kind == "static":
|
||||
return
|
||||
|
||||
derive = item["default"]["derive"]
|
||||
if isinstance(derive, str):
|
||||
if not derive.strip():
|
||||
raise CannedPromptError(f"input {name!r}: derive reference is empty")
|
||||
if derive not in dependency_ids:
|
||||
raise CannedPromptError(
|
||||
f"input {name!r} derives from {derive!r}, which is not declared "
|
||||
"in dependencies.prompts"
|
||||
)
|
||||
return
|
||||
if isinstance(derive, dict):
|
||||
prompt = derive.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
raise CannedPromptError(
|
||||
f"input {name!r}: inline derive requires a non-empty prompt"
|
||||
)
|
||||
extra = sorted(set(derive) - {"prompt"})
|
||||
if extra:
|
||||
raise CannedPromptError(
|
||||
f"input {name!r}: an inline derive cannot also reference a "
|
||||
"dependency; unexpected keys: " + ", ".join(extra)
|
||||
)
|
||||
return
|
||||
raise CannedPromptError(
|
||||
f"input {name!r}: derive must be a dependency id or a mapping with a prompt"
|
||||
)
|
||||
|
||||
|
||||
def validate_package(package_dir: Path) -> dict[str, Any]:
|
||||
package_dir = package_dir.resolve()
|
||||
if not package_dir.is_dir():
|
||||
|
|
@ -127,6 +200,11 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
|
|||
safe_relative_file(package_dir, relative, field)
|
||||
|
||||
names = declared_names(manifest)
|
||||
|
||||
dependency_ids = prompt_dependency_ids(manifest)
|
||||
for item in manifest.get("inputs") or []:
|
||||
validate_input_default(item, dependency_ids)
|
||||
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
placeholders = set(PLACEHOLDER_RE.findall(template))
|
||||
undeclared = sorted(placeholders - names)
|
||||
|
|
@ -305,8 +383,23 @@ def supplied_values(pairs: list[str]) -> dict[str, str]:
|
|||
return values
|
||||
|
||||
|
||||
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
|
||||
resolved: dict[str, Any] = {}
|
||||
@dataclass
|
||||
class Resolution:
|
||||
"""The outcome of § 5.1 resolution, separate from rendering."""
|
||||
|
||||
values: dict[str, Any] = field(default_factory=dict)
|
||||
origins: dict[str, str] = field(default_factory=dict)
|
||||
underivable: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Resolution:
|
||||
"""Resolve inputs and parameters per § 5.1.
|
||||
|
||||
This tool never calls a model, so a derived default is satisfied only by its
|
||||
static fallback `value`. An input with a derived default and no fallback is
|
||||
reported as underivable rather than silently rendered as empty.
|
||||
"""
|
||||
result = Resolution()
|
||||
known: set[str] = set()
|
||||
|
||||
inputs = manifest.get("inputs") or []
|
||||
|
|
@ -314,23 +407,48 @@ def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict
|
|||
name = item["name"]
|
||||
known.add(name)
|
||||
if name in raw_values:
|
||||
resolved[name] = raw_values[name]
|
||||
elif item.get("required", False):
|
||||
result.values[name] = raw_values[name]
|
||||
result.origins[name] = "supplied"
|
||||
continue
|
||||
if item.get("required", False):
|
||||
raise CannedPromptError(f"missing required input: {name}")
|
||||
|
||||
kind = input_default_kind(item)
|
||||
if kind == "static":
|
||||
result.values[name] = item["default"]
|
||||
result.origins[name] = "default"
|
||||
elif kind == "derived":
|
||||
default = item["default"]
|
||||
if "value" in default:
|
||||
result.values[name] = default["value"]
|
||||
result.origins[name] = "fallback (not derived)"
|
||||
else:
|
||||
result.origins[name] = "unresolved (derived, no fallback)"
|
||||
result.underivable.append(name)
|
||||
else:
|
||||
result.origins[name] = "unresolved (optional, no default)"
|
||||
|
||||
parameters = manifest.get("parameters") or {}
|
||||
for name, spec in parameters.items():
|
||||
known.add(name)
|
||||
if name in raw_values:
|
||||
resolved[name] = coerce_value(raw_values[name], spec)
|
||||
result.values[name] = coerce_value(raw_values[name], spec)
|
||||
result.origins[name] = "supplied"
|
||||
elif "default" in spec:
|
||||
resolved[name] = spec["default"]
|
||||
result.values[name] = spec["default"]
|
||||
result.origins[name] = "default"
|
||||
else:
|
||||
result.origins[name] = "unresolved (no default)"
|
||||
|
||||
unknown = sorted(set(raw_values) - known)
|
||||
if unknown:
|
||||
raise CannedPromptError("unknown values: " + ", ".join(unknown))
|
||||
|
||||
return resolved
|
||||
return result
|
||||
|
||||
|
||||
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
|
||||
return resolve_inputs(manifest, raw_values).values
|
||||
|
||||
|
||||
def render_template(template: str, values: dict[str, Any]) -> str:
|
||||
|
|
@ -354,11 +472,65 @@ def cmd_render(args: argparse.Namespace) -> None:
|
|||
manifest = validate_package(package_dir)
|
||||
template_path = safe_relative_file(package_dir, manifest["template"], "template")
|
||||
raw = supplied_values(args.set_values)
|
||||
values = resolve_values(manifest, raw)
|
||||
rendered = render_template(template_path.read_text(encoding="utf-8"), values)
|
||||
resolution = resolve_inputs(manifest, raw)
|
||||
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
used = set(PLACEHOLDER_RE.findall(template))
|
||||
blocked = sorted(used.intersection(resolution.underivable))
|
||||
if blocked:
|
||||
raise CannedPromptError(
|
||||
"cannot render: this tool does not derive values, and these inputs "
|
||||
"declare a derived default with no static fallback: "
|
||||
+ ", ".join(blocked)
|
||||
+ " — supply them with --set, or add a fallback `value` to the default"
|
||||
)
|
||||
|
||||
rendered = render_template(template, resolution.values)
|
||||
print(rendered, end="" if rendered.endswith("\n") else "\n")
|
||||
|
||||
|
||||
def cmd_resolve(args: argparse.Namespace) -> None:
|
||||
catalog = Path(args.catalog).expanduser()
|
||||
package_dir = resolve_installed(catalog, args.id, args.version)
|
||||
manifest = validate_package(package_dir)
|
||||
raw = supplied_values(args.set_values)
|
||||
resolution = resolve_inputs(manifest, raw)
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"values": resolution.values,
|
||||
"origins": resolution.origins,
|
||||
"underivable": resolution.underivable,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
width = max((len(name) for name in resolution.origins), default=0)
|
||||
for name, origin in resolution.origins.items():
|
||||
if name in resolution.values:
|
||||
preview = str(resolution.values[name]).replace("\n", " ")
|
||||
if len(preview) > 48:
|
||||
preview = preview[:45] + "..."
|
||||
print(f"{name:<{width}} {origin:<32} {preview}")
|
||||
else:
|
||||
print(f"{name:<{width}} {origin}")
|
||||
if resolution.underivable:
|
||||
print()
|
||||
plural = len(resolution.underivable) > 1
|
||||
print(
|
||||
"note: "
|
||||
+ ", ".join(resolution.underivable)
|
||||
+ (" declare" if plural else " declares")
|
||||
+ " a derived default this tool cannot satisfy; rendering will fail "
|
||||
+ ("if the template uses them" if plural else "if the template uses it")
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="canned-prompts", description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
|
@ -388,6 +560,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
render.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
|
||||
render.set_defaults(func=cmd_render)
|
||||
|
||||
resolve = sub.add_parser("resolve", help="report how each input and parameter resolves")
|
||||
resolve.add_argument("id")
|
||||
resolve.add_argument("--version")
|
||||
resolve.add_argument("--catalog", default=str(default_catalog()))
|
||||
resolve.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
|
||||
resolve.add_argument("--json", action="store_true")
|
||||
resolve.set_defaults(func=cmd_resolve)
|
||||
|
||||
install = sub.add_parser("install", help="install a package from a filesystem registry")
|
||||
install.add_argument("id")
|
||||
install.add_argument("--version")
|
||||
|
|
|
|||
|
|
@ -51,3 +51,172 @@ def test_undeclared_placeholder_fails(package: Path) -> None:
|
|||
(package / "prompt.md").write_text("{{ missing }}\n", encoding="utf-8")
|
||||
with pytest.raises(cp.CannedPromptError, match="undeclared placeholders"):
|
||||
cp.validate_package(package)
|
||||
|
||||
|
||||
def write_pkg(pkg: Path, manifest: str, template: str = "{{ greeting }}\n") -> Path:
|
||||
pkg.mkdir(exist_ok=True)
|
||||
(pkg / "prompt.yaml").write_text(manifest, encoding="utf-8")
|
||||
(pkg / "prompt.md").write_text(template, encoding="utf-8")
|
||||
return pkg
|
||||
|
||||
|
||||
BASE = """\
|
||||
format: canned-prompt/v0.1
|
||||
id: demo/defaults
|
||||
name: Defaults
|
||||
version: 1.0.0
|
||||
summary: Exercise input defaults.
|
||||
template: prompt.md
|
||||
"""
|
||||
|
||||
|
||||
def test_static_default_fills_optional_input(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default: "hello there"
|
||||
""",
|
||||
)
|
||||
manifest = cp.validate_package(pkg)
|
||||
resolution = cp.resolve_inputs(manifest, {})
|
||||
assert resolution.values["greeting"] == "hello there"
|
||||
assert resolution.origins["greeting"] == "default"
|
||||
assert cp.render_template("{{ greeting }}", resolution.values) == "hello there"
|
||||
|
||||
|
||||
def test_supplied_value_overrides_static_default(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default: "hello there"
|
||||
""",
|
||||
)
|
||||
manifest = cp.validate_package(pkg)
|
||||
resolution = cp.resolve_inputs(manifest, {"greeting": "hi"})
|
||||
assert resolution.values["greeting"] == "hi"
|
||||
assert resolution.origins["greeting"] == "supplied"
|
||||
|
||||
|
||||
def test_derived_default_uses_static_fallback(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
dependencies:
|
||||
prompts:
|
||||
- id: context/greeting
|
||||
version: 1.0.0
|
||||
requirement: generate
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
derive: context/greeting
|
||||
value: "(none)"
|
||||
""",
|
||||
)
|
||||
manifest = cp.validate_package(pkg)
|
||||
resolution = cp.resolve_inputs(manifest, {})
|
||||
assert resolution.values["greeting"] == "(none)"
|
||||
assert resolution.origins["greeting"] == "fallback (not derived)"
|
||||
assert resolution.underivable == []
|
||||
|
||||
|
||||
def test_derived_default_without_fallback_is_underivable(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
dependencies:
|
||||
prompts:
|
||||
- id: context/greeting
|
||||
version: 1.0.0
|
||||
requirement: generate
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
derive: context/greeting
|
||||
""",
|
||||
)
|
||||
manifest = cp.validate_package(pkg)
|
||||
resolution = cp.resolve_inputs(manifest, {})
|
||||
assert resolution.underivable == ["greeting"]
|
||||
assert "greeting" not in resolution.values
|
||||
with pytest.raises(cp.CannedPromptError, match="unresolved placeholder"):
|
||||
cp.render_template("{{ greeting }}", resolution.values)
|
||||
|
||||
|
||||
def test_inline_derive_is_valid(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
derive:
|
||||
prompt: Produce a greeting suited to the audience.
|
||||
value: "(none)"
|
||||
""",
|
||||
)
|
||||
manifest = cp.validate_package(pkg)
|
||||
assert cp.resolve_inputs(manifest, {}).values["greeting"] == "(none)"
|
||||
|
||||
|
||||
def test_default_with_required_true_fails(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: true
|
||||
default: "hello"
|
||||
""",
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="required: true"):
|
||||
cp.validate_package(pkg)
|
||||
|
||||
|
||||
def test_derive_reference_must_be_declared(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
derive: context/greeting
|
||||
""",
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="not declared in dependencies"):
|
||||
cp.validate_package(pkg)
|
||||
|
||||
|
||||
def test_inline_derive_cannot_also_reference(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
derive:
|
||||
prompt: Produce a greeting.
|
||||
id: context/greeting
|
||||
""",
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="cannot also reference"):
|
||||
cp.validate_package(pkg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue