Skip to content
Open
22 changes: 22 additions & 0 deletions .pdd/verification-profile-rotations.json
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,28 @@
"head_policy_sha256": "faf427d3891e0c4eb38a1d25d4d03f1fa4f24bcac642b7658bd65ceeaf52b953",
"base_prompt_sha256": "882876b0dea9198c1fa9492806d85bfb088b393096f4f1a0543cc8f31f40fc30",
"head_prompt_sha256": "a3eb3060fcf46f6534f1d483693c659b2a5d4068c5b309798c591b9166ee2704"
},
{
"prompt_path": "pdd/prompts/generate_story_contract_LLM.prompt",
"language_id": "llm",
"from_requirement_id": "CONTRACT-SHA256:415e054e596f1b103e999a1f7dca848143c2d8be09b8b1723cd01ead1fe66fc6",
"to_requirement_id": "CONTRACT-SHA256:d51e79e13bb784b75b966728eaf3e56e8660d363d5c6729ede11b171af6e8e7c",
"policy_path": ".pdd/verification-profiles.json",
"base_policy_sha256": "faf427d3891e0c4eb38a1d25d4d03f1fa4f24bcac642b7658bd65ceeaf52b953",
"head_policy_sha256": "c5ec62e0907d4301cb26b8a63e56066fe57df2a48ceb35caa7d3b600dde6c49b",
"base_prompt_sha256": "415e054e596f1b103e999a1f7dca848143c2d8be09b8b1723cd01ead1fe66fc6",
"head_prompt_sha256": "d51e79e13bb784b75b966728eaf3e56e8660d363d5c6729ede11b171af6e8e7c"
},
{
"prompt_path": "pdd/prompts/user_story_tests_python.prompt",
"language_id": "python",
"from_requirement_id": "CONTRACT-SHA256:1c467034344d9d87b8225995bc458bc8093e6759dd5c2eed8424b345f69a3ba7",
"to_requirement_id": "CONTRACT-SHA256:cbdfe0b435c9ee05f71a3279278b3e1d2d719803e8a28a0fe82b961e22d4e2d9",
"policy_path": ".pdd/verification-profiles.json",
"base_policy_sha256": "faf427d3891e0c4eb38a1d25d4d03f1fa4f24bcac642b7658bd65ceeaf52b953",
"head_policy_sha256": "c5ec62e0907d4301cb26b8a63e56066fe57df2a48ceb35caa7d3b600dde6c49b",
"base_prompt_sha256": "1c467034344d9d87b8225995bc458bc8093e6759dd5c2eed8424b345f69a3ba7",
"head_prompt_sha256": "cbdfe0b435c9ee05f71a3279278b3e1d2d719803e8a28a0fe82b961e22d4e2d9"
}
],
"requirement_rotation_retirements": [
Expand Down
8 changes: 4 additions & 4 deletions .pdd/verification-profiles.json
Original file line number Diff line number Diff line change
Expand Up @@ -7421,7 +7421,7 @@
"prompt_path": "pdd/prompts/generate_story_contract_LLM.prompt",
"language_id": "llm",
"required_requirement_ids": [
"CONTRACT-SHA256:415e054e596f1b103e999a1f7dca848143c2d8be09b8b1723cd01ead1fe66fc6"
"CONTRACT-SHA256:d51e79e13bb784b75b966728eaf3e56e8660d363d5c6729ede11b171af6e8e7c"
],
"obligations": [
{
Expand All @@ -7430,7 +7430,7 @@
"validator_id": "threshold-ed25519",
"validator_config_digest": "threshold-ed25519-v1",
"requirement_ids": [
"CONTRACT-SHA256:415e054e596f1b103e999a1f7dca848143c2d8be09b8b1723cd01ead1fe66fc6"
"CONTRACT-SHA256:d51e79e13bb784b75b966728eaf3e56e8660d363d5c6729ede11b171af6e8e7c"
],
"artifact_paths": [
"pdd/prompts/generate_story_contract_LLM.prompt"
Expand Down Expand Up @@ -10351,7 +10351,7 @@
"prompt_path": "pdd/prompts/user_story_tests_python.prompt",
"language_id": "python",
"required_requirement_ids": [
"CONTRACT-SHA256:1c467034344d9d87b8225995bc458bc8093e6759dd5c2eed8424b345f69a3ba7"
"CONTRACT-SHA256:cbdfe0b435c9ee05f71a3279278b3e1d2d719803e8a28a0fe82b961e22d4e2d9"
],
"obligations": [
{
Expand All @@ -10360,7 +10360,7 @@
"validator_id": "threshold-ed25519",
"validator_config_digest": "threshold-ed25519-v1",
"requirement_ids": [
"CONTRACT-SHA256:1c467034344d9d87b8225995bc458bc8093e6759dd5c2eed8424b345f69a3ba7"
"CONTRACT-SHA256:cbdfe0b435c9ee05f71a3279278b3e1d2d719803e8a28a0fe82b961e22d4e2d9"
],
"artifact_paths": [
"pdd/prompts/user_story_tests_python.prompt"
Expand Down
31 changes: 26 additions & 5 deletions pdd/prompts/generate_story_contract_LLM.prompt
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@
<primary_prompts>
{PRIMARY_PROMPTS}
</primary_prompts>

% Deterministically-resolved, importable module interfaces for the prompts
% already linked to this Story — computed by the tooling, not the model.
% Only a block with a `module:` line and a `functions:` list is usable as a
% behavioral test Entry Point; a block with no `module:` line means that
% prompt has no deterministic import binding (a CLI/Click interface, or one
% whose module path could not be resolved) and MUST NOT be used as one.
<primary_prompt_interfaces>
{PRIMARY_PROMPT_INTERFACES}
</primary_prompt_interfaces>
</input>

% What the contract must do
Expand All @@ -39,8 +49,10 @@
- Pull concrete, behavior-changing detail from the issue (per-source vs aggregate, `--json`, a `--threshold` exit code, "MUST NOT make an LLM call") into `## Covers`, `## Acceptance Criteria`, and `## Oracle`. Preserve distinctions precisely; do not collapse them into vague wording, and do not broaden a requirement beyond the issue/Story.
- `## Covers` lists the named requirements/acceptance-criteria the story exercises (`- AC1: <summary>` / `- R1: <summary>`), specific enough that removing that behavior fails validation.
- `## Acceptance Criteria` are concrete Given/When/Then statements about observable behavior (prefer 2–5).
- `## Oracle` lists what decides pass/fail (error type, state transition, presence/absence of an external call, returned value shape). `## Non-Oracle` lists what must NOT matter.
- `## Negative Cases` lists forbidden outcomes (from MUST NOT / SHALL NOT rules or obviously-wrong behavior).
- `## Entry Point` is required. Copy `module:` and one name from that block's `functions:` list VERBATIM from a `<primary_prompt_interfaces>` block that has a `module:` line — never invent, guess, or modify either string, and never select a block that has no `module:` line (it has no deterministic import binding — e.g. a CLI/Click command — and cannot be called directly). Use Python literals for `args` (a list) and `kwargs` (a dict); choose safe, deterministic arguments that exercise this Story. If NO linked prompt has a usable `module:` block, write `- module: none` and `- callable: none` and explain the gap in `## Notes`; this is a normal, valid outcome, not an error.
- `## Seams` is required but may contain only `- none` when no runtime boundary needs patching. Otherwise each bullet must be `dotted.import.path = <Python literal>` and must make the behavioral test deterministic without changing the behavior under test.
- `## Oracle` and `## Negative Cases` bullets are each a single executable Python boolean expression evaluated with `result` bound to the Entry Point's return value — e.g. `result == 200`, `isinstance(result, dict)`, `"error" not in result`, `result.get("status") == "ok"`. Never write prose (no "returned value shape", no "error type" as free text) and never call anything other than a plain method/attribute on `result` or one of `len`, `isinstance`, `str`, `int`, `float`, `bool`, `abs`, `round`, `sorted`, `min`, `max`, `sum`, `any`, `all`, `repr`, `type` — no other function calls, no dunder names, no imports; those bullets are executed, not just displayed. If `## Entry Point` is `- module: none`, or a Story detail genuinely cannot be expressed as such a boolean expression over `result`, omit it from `## Oracle`/`## Negative Cases` and note it under `## Notes` instead. `## Oracle` lists what decides pass/fail; `## Non-Oracle` lists what must NOT matter (prose is fine there — it is not compiled).
- `## Negative Cases` lists forbidden outcomes (from MUST NOT / SHALL NOT rules or obviously-wrong behavior), each as the same kind of boolean expression over `result` (e.g. `result.get("called_llm") is False`).
- `## Non-Goals` reflects the issue's own non-goals when stated.
</guidance>

Expand Down Expand Up @@ -78,16 +90,25 @@
1. Given <state>, when <action>, then <observable outcome>.
2. ...

## Entry Point
- module: <module: value copied verbatim from a primary_prompt_interfaces block, or `none`>
- callable: <a name from that block's functions: list, or `none`>
- args: [<Python literals>]
- kwargs: {<keyword>: <Python literal>}

## Seams
- none

## Oracle
These details matter for pass/fail:
- <detail>
These details matter for pass/fail (each bullet a boolean expression over `result`):
- <result == ... / isinstance(result, ...) / result.attr == ... / "x" in result>

## Non-Oracle
These details should not matter:
- <detail>

## Negative Cases
- <forbidden outcome this story protects against>
- <boolean expression over `result` that is False for the forbidden outcome this story protects against>

## Non-Goals
- <what this story explicitly does not cover>
Expand Down
27 changes: 24 additions & 3 deletions pdd/prompts/user_story_tests_python.prompt
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ precise:
- The machine-checkable contract is GENERATED from the human Story + the original
issue and lives at `user_stories/contracts/<slug>.contract.md`. It carries the
top-level (`##`) sections `## Covers`, `## Context`, `## Acceptance Criteria`,
`## Oracle`, `## Non-Oracle`, `## Negative Cases`, `## Non-Goals`,
`## Entry Point`, `## Seams`, `## Oracle`, `## Non-Oracle`, `## Negative Cases`, `## Non-Goals`,
Comment thread
agarwal-ishaan marked this conversation as resolved.
`## Candidate Prompts`, `## Notes` (`_REQUIRED_CONTRACT_SECTIONS`), under a
`<!-- pdd-story-contract derived-from-story="..." story-hash="..."
issue-ref="..." -->` header used for sync.
Expand Down Expand Up @@ -147,7 +147,8 @@ varies during normal development goes in `## Non-Oracle`.
such as `<persona>`, `<capability>`, or `<benefit>` (strip a wrapping
``` ``` ``` fence first). The contract generator (`_llm_generate_story_contract`)
accepts only when its output carries the full `_REQUIRED_CONTRACT_SECTIONS` set
(`## Covers`, `## Context`, `## Acceptance Criteria`, `## Oracle`,
(`## Covers`, `## Context`, `## Acceptance Criteria`, `## Entry Point`,
`## Seams`, `## Oracle`,
`## Non-Oracle`, `## Negative Cases`, `## Non-Goals`, `## Candidate Prompts`,
`## Notes`) and no placeholders. When the LLM is unavailable (no provider key,
offline, error) OR the output is empty, missing a required section, or
Expand Down Expand Up @@ -315,9 +316,29 @@ R2 (MUST NOT): Print the diagnostic block when `quiet` is true.
- `generate_user_story` writes the human Story file via
`_llm_generate_story_markdown` (requires `## Story`) and then the contract
file via `_generate_and_write_contract` / `_llm_generate_story_contract`
(requires the `_REQUIRED_CONTRACT_SECTIONS` set incl. `## Candidate Prompts`).
(requires the `_REQUIRED_CONTRACT_SECTIONS` set incl. `## Entry Point`,
`## Seams`, and `## Candidate Prompts`).
It returns failure without writing anything when the human story is
unavailable/invalid; a contract failure is non-blocking.
- `## Entry Point`/`## Seams` are populated from `PRIMARY_PROMPT_INTERFACES`,
which `_resolve_prompt_interfaces` builds from each linked prompt's own
`<pdd-interface>` block PLUS a module path PDD's file-layout convention
derives deterministically from the prompt's path
(`_module_path_for_prompt`) — never from the model. Only a `type: module`
interface with a resolvable module path is offered as usable; a `type: cli`
interface (or one whose path can't be resolved) is listed as explicitly
unsupported so the model cannot invent an import binding for it.
`_validate_contract_entry_point_and_assertions` then rejects (contract not
written; generation fails closed, same as a missing required section) any
LLM output whose `## Entry Point` module/callable isn't exactly one of
those offered pairs (`- module: none` / `- callable: none` is the only
accepted "no deterministic entry point" case), or whose `## Oracle`/
`## Negative Cases` bullets aren't syntactically-safe boolean expressions
over `result` per `story_test_generator._assertion_from_bullet`'s
allowlist (comparisons/boolean logic/literals/attribute+index access on
`result`, calls only to a small pure-builtin allowlist, no dunder names —
the deepest enforcement point, since these bullets are spliced into
`assert {expr}` in generated pytest that CI executes).
- `_llm_generate_story_markdown` takes the resolved issue text/ref (NOT prompt
content), loads the `generate_user_story_LLM` meta-prompt (`load_prompt_template`),
escapes it via `preprocess(double_curly_brackets=True,
Expand Down
25 changes: 23 additions & 2 deletions pdd/story_test_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ def _bullets(text: str) -> list[str]:
return rows


def _entry_point_has_binding(md_sections: dict[str, str]) -> bool:
"""True when ``## Entry Point`` declares a real module/callable pair.

``- module: none`` is the contract generator's explicit "no
deterministically-bound callable for this Story" marker (see
``user_story_tests._validate_contract_entry_point_and_assertions``); such a
contract must fall through to the text-pinning generator below, same as a
contract with no ``## Entry Point`` heading at all.
"""
entry_text = _section(md_sections, "Entry Point").lower()
for bullet in _bullets(entry_text):
if bullet.startswith("module:") and bullet.split(":", 1)[1].strip() == "none":
return False
return True


def _literal_list(values: list[str], *, indent: str = " ") -> str:
if not values:
return "[]"
Expand Down Expand Up @@ -280,8 +296,13 @@ def generate_story_regression_test(
# (missing `- module:`/`- callable:`) instead of silently degrading to a
# text-pin that the user would mistake for a real behavioral oracle
# (pdd#1889 C-F7). A contract with no ## Entry Point at all still falls
# through to the text-pinning generator below.
if "entry point" in md_sections:
# through to the text-pinning generator below. The one deliberate
# exception is `- module: none`: the contract generator's own validation
# (`_validate_contract_entry_point_and_assertions`) only ever writes that
# exact marker when it found no deterministically-bound callable for this
# Story, so it is a confirmed "no entry point" outcome, not a malformed
# one, and also falls through to the text-pinning generator.
if "entry point" in md_sections and _entry_point_has_binding(md_sections):
return _generate_behavioral_test(story_path, output)

oracle_text = _section(md_sections, "Oracle", "Acceptance Criteria", "Story")
Expand Down
109 changes: 107 additions & 2 deletions pdd/story_test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,110 @@ def _key_values(section: str) -> dict[str, str]:
return values


# Assertion bullets are issue/LLM-derived text that gets spliced verbatim into
# `assert {expr}` in a generated pytest file the story-regression CI lane
# executes -- and that file also has `importlib` and `module` in module scope
# (see `render_story_test`), so a permissive allowlist doesn't just risk
# builtins like `__import__('os').system(...)`; a bare name check alone lets
# `importlib.import_module("os").system(...)` and `module.os.system(...)`
# through too. Syntax validity alone does not make an expression safe, so this
# allowlist constrains bullets to a read-only "compare/inspect `result`" DSL:
# - only `result` and a small set of pure builtins may appear as bare names
# - every attribute/subscript chain must be rooted at `result` (not at
# `module`, `importlib`, or any other name in the generated test's scope)
# - a call is either one of the safe builtins, or a read-only method looked up
# on `result` (never a mutator like `result.clear()`)
_SAFE_ASSERTION_CALL_NAMES = frozenset(
{
"len", "isinstance", "str", "int", "float", "bool", "abs", "round",
"sorted", "min", "max", "sum", "any", "all", "repr", "type",
"dict", "list", "tuple", "set",
}
)
_SAFE_RESULT_METHOD_NAMES = frozenset(
{
"get", "keys", "values", "items", "count", "index",
"lower", "upper", "strip", "lstrip", "rstrip",
"startswith", "endswith", "split", "splitlines", "join",
"isdigit", "isalpha", "isalnum", "isupper", "islower", "isspace",
"copy",
}
)
_SAFE_ASSERTION_NAMES = frozenset({"result"}) | _SAFE_ASSERTION_CALL_NAMES
_SAFE_ASSERTION_NODE_TYPES = (
ast.Expression, ast.BoolOp, ast.UnaryOp, ast.BinOp, ast.Compare, ast.Call,
ast.Name, ast.Load, ast.Constant, ast.Attribute, ast.Subscript, ast.Slice,
ast.List, ast.Tuple, ast.Dict, ast.Set,
ast.And, ast.Or, ast.Not, ast.Invert, ast.UAdd, ast.USub,
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.FloorDiv, ast.Mod, ast.Pow,
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, ast.Is, ast.IsNot,
ast.In, ast.NotIn,
)


def _chain_root(node: ast.AST) -> ast.AST:
"""Walk down an Attribute/Subscript chain to the value it's rooted at."""
while isinstance(node, (ast.Attribute, ast.Subscript)):
node = node.value
return node


def _is_rooted_at_result(node: ast.AST) -> bool:
root = _chain_root(node)
return isinstance(root, ast.Name) and root.id == "result"


def _ensure_safe_assertion(tree: ast.AST, bullet: str) -> None:
"""Reject any assertion expression outside the safe result-inspection DSL."""
for node in ast.walk(tree):
if not isinstance(node, _SAFE_ASSERTION_NODE_TYPES):
raise ValueError(
"Story assertion bullets may only compare/inspect `result` "
f"(unsupported construct {type(node).__name__}): {bullet!r}"
)
if isinstance(node, ast.Name) and node.id not in _SAFE_ASSERTION_NAMES:
raise ValueError(
"Story assertion bullet references a name other than `result` "
f"or a safe helper: {bullet!r}"
)
if isinstance(node, ast.Attribute):
if node.attr.startswith("_"):
raise ValueError(
f"Story assertion bullet accesses a private/dunder attribute: {bullet!r}"
)
if not _is_rooted_at_result(node):
raise ValueError(
"Story assertion bullet accesses an attribute not rooted at "
f"`result`: {bullet!r}"
)
if isinstance(node, ast.Subscript) and not _is_rooted_at_result(node):
raise ValueError(
f"Story assertion bullet subscripts something other than `result`: {bullet!r}"
)
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name):
if func.id not in _SAFE_ASSERTION_CALL_NAMES:
raise ValueError(
f"Story assertion bullet calls disallowed function {func.id!r}; "
f"only {sorted(_SAFE_ASSERTION_CALL_NAMES)} are permitted: {bullet!r}"
)
elif isinstance(func, ast.Attribute):
if not _is_rooted_at_result(func):
raise ValueError(
"Story assertion bullet calls a method not rooted at "
f"`result`: {bullet!r}"
)
if func.attr not in _SAFE_RESULT_METHOD_NAMES:
raise ValueError(
f"Story assertion bullet calls disallowed `result` method "
f"{func.attr!r}; only read-only methods "
f"{sorted(_SAFE_RESULT_METHOD_NAMES)} are permitted: {bullet!r}"
)
else:
raise ValueError(f"Story assertion bullet has an unsupported call target: {bullet!r}")


def _assertion_from_bullet(bullet: str) -> str:
text = bullet.strip()
if text.startswith("assert "):
Expand All @@ -90,12 +194,13 @@ def _assertion_from_bullet(bullet: str) -> str:
if not expr:
raise ValueError("Story assertion bullet is empty.")
try:
ast.parse(expr, mode="eval")
tree = ast.parse(expr, mode="eval")
except SyntaxError as exc:
raise ValueError(
"Story test generation requires Oracle/Negative Cases bullets to be "
f"Python assertion expressions; got: {bullet!r}"
f"Python assertion expressions over `result`; got: {bullet!r}"
) from exc
_ensure_safe_assertion(tree, bullet)
return expr


Expand Down
1 change: 1 addition & 0 deletions pdd/sync_core/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@
_CONFORMANCE_SPLIT_PROFILE_BYTES[1],
_CONFORMANCE_SPLIT_PROFILE_BYTES[1],
)

_PR2316_STALE_LLM_REISSUE_HISTORY_PROFILE_BYTES = (
_OPUS_FABLE_COMPOSED_PROFILE_BYTES[1],
_TEMPERATURE_REGRESSION_PROFILE_BYTES[1],
Expand Down
Loading