Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,52 @@ Typed Dream exports preserve source identities and provenance. See

Verification CI sets `HOWL_FORBID_LOCAL_INFERENCE=1`. The shared provider dependency
is pinned to its final reviewed commit, including portable command-provider tests.

## Recovery and bounded repair

```bash
export HOWL_FORBID_LOCAL_INFERENCE=1
howlcreate explore "Design an accessible museum queue" --provider deterministic \
--repair-attempts 1 --max-calls 32 --format json --output run.json
howlcreate resume run.json --provider deterministic --output resumed.json
```

For a reviewed remote command, replace the provider with `--provider command
--command-config /absolute/path/profile.json`. The same explicit profile must be
supplied on resume; generated artifacts never select executable commands. Use
provider-core's `claude-json`, `openai-json`, `gemini-json`, `generic-json`, or raw
text adapters as appropriate to actual CLI output. Model/usage/cost come from
reported output, never the configured model label.

Structured responses have at most one schema-only repair call (disable with
`--repair-attempts 0`). Original and repaired attempts have separate execution
records and consume the same finite call budget. Failed parsing preserves known
usage/cost and bounded redacted decoded output plus its hash. Repair does not
invite creative regeneration. A repair call can add cost; no savings are assumed.

Atomic private JSON checkpoints persist at operator boundaries and pre-convergence.
They retain run/idea IDs, graph, assumptions, completed phases, mutation seed IDs,
constraints, scores, budget and execution history. Checkpoints use
`howlcreate.checkpoint/v1`; older saved reports cannot resume. Resume retains the
original total call limit and verifies provider identity/profile hash. It skips
completed phases and already-scored concepts, appending new execution records.
A failed resume retains prior valid finalists and decisions, explicitly marked
`retained_finalists_from_checkpoint` until convergence refreshes them.
There is one writer per run; concurrent resume is unsupported. A crash during an
uncheckpointed in-flight call can leave its usage unknown; it cannot recover a
remote receipt that was never received. Historical phase failures remain visible and
keep a resumed run PARTIAL even after recovery. A fully completed interrupted run
can be COMPLETE; its interruption stays in resume_history rather than the active stop reason.

If a later optional phase has malformed output after repair, surviving valid
concepts can converge. Finalists require valid evaluations and hard-constraint
eligibility. Failed evaluation batches retain earlier scores; unevaluated ideas
cannot become finalists. Status stays PARTIAL with completion_detail
PARTIAL_WITH_FINALISTS or PARTIAL_NO_FINALISTS and named phase failures.
Authentication/session/cancellation/budget failures dispatch no recovery calls.
No provider fallback is discovered or authorized automatically.

Executions record sampling requested/supported/applied. Commands and deterministic
fixtures do not claim sampling control; HTTP applied means transmitted in the
request, not independently verified backend behavior. Unknown usage/cost remains
unknown. This is a recovery workflow, not evidence that partial runs equal full runs.
11 changes: 11 additions & 0 deletions change_log.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ provider-backed develop. The former template behavior is now scaffold; the two-a
Python API is deprecated. Removed fixed domain branches and fixture substring routing;
strategic_fit now scores the user's objective, with optional explicit ecosystem weighting.
Runtime dependencies now include pinned howl-provider-core and jsonschema.

## Resilience hardening (2026-10-02)

- Added one bounded budgeted structured repair, usage preservation and sampling records.
- Added atomic phase checkpoints and compatible same-provider resume with retained IDs/history.
- Optional malformed later phases and partial evaluations may converge valid surviving work.
- Added compact Create constraint metadata to the existing advisory Dream handoff.

- Resume retains prior valid finalists and decisions when recovery stops before new convergence.

- Pin provider-core nested CLI response-shape telemetry fix.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ license = "MIT"
authors = [
{ name = "howlcipher" }
]
dependencies = ["howl-provider-core @ git+https://github.com/howlcipher/howl-provider-core.git@e7cdf2c43705f93b69b6120157cbb55817a3a2af", "jsonschema>=4.20,<5"]
dependencies = ["howl-provider-core @ git+https://github.com/howlcipher/howl-provider-core.git@5837d467a76e2f9e03b6f32a0feb957f9e48f95e", "jsonschema>=4.20,<5"]

[project.optional-dependencies]
dev = [
Expand Down
26 changes: 22 additions & 4 deletions src/howlcreate/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,12 @@ def _print_step(phase: str, payload: dict) -> None:
def cmd_explore(args: argparse.Namespace) -> int:
"""Execute the full divergent & convergent creative exploration pipeline."""
hard_constraints = (
getattr(args, "hard_constraints", None)
or getattr(args, "hard_constraint", None)
or []
getattr(args, "hard_constraints", None) or getattr(args, "hard_constraint", None) or []
)
config = PipelineConfig(
top_n=args.top_n,
max_calls=args.max_calls,
repair_attempts=args.repair_attempts,
ecosystem_fit_weight=args.ecosystem_fit_weight,
provider_name=args.provider,
hard_constraints=hard_constraints,
Expand Down Expand Up @@ -101,6 +100,17 @@ def cmd_explore(args: argparse.Namespace) -> int:
return 0 if record.metadata.get("status") == "COMPLETE" else 1


def cmd_resume(args):
pipeline = CreativePipeline()
record = pipeline.resume(args.run_id, provider=_provider(args))
text = json.dumps(record.to_dict(), indent=2)
if args.output:
Path(args.output).write_text(text, encoding="utf-8")
else:
print(text)
return 0 if record.metadata.get("status") == "COMPLETE" else 1


def cmd_assumptions(args: argparse.Namespace) -> int:
"""Extract explicit & implicit assumptions and generate inversions."""
provider = _provider(args)
Expand Down Expand Up @@ -318,6 +328,13 @@ def build_parser() -> argparse.ArgumentParser:
)
explore_parser.set_defaults(func=cmd_explore)

explore_parser.add_argument("--repair-attempts", type=int, choices=[0, 1], default=1)
resume_parser = subparsers.add_parser("resume", help="Resume a compatible saved checkpoint")
resume_parser.add_argument("run_id", help="Run ID or checkpoint file")
resume_parser.add_argument("--provider", default="auto")
resume_parser.add_argument("--output", "-o")
resume_parser.set_defaults(func=cmd_resume)

# assumptions
asm_parser = subparsers.add_parser(
"assumptions", help="Extract and invert implicit assumptions"
Expand Down Expand Up @@ -384,7 +401,8 @@ def build_parser() -> argparse.ArgumentParser:
help="Explicit trusted operator JSON profile (never discovered)",
)
sub.add_argument("--allow-local", action="store_true")
sub.add_argument("--max-calls", type=int, default=32)
if sub is not resume_parser:
sub.add_argument("--max-calls", type=int, default=32)
sub.add_argument("--fallback", nargs="*", default=[])

return parser
Expand Down
31 changes: 27 additions & 4 deletions src/howlcreate/engine/convergence.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from howlcreate.models.run import ConvergenceDecision
from howlcreate.providers.base import BaseProvider
from howl_provider_core import ProviderError


class ConvergenceEngine:
Expand Down Expand Up @@ -279,6 +280,7 @@ def converge(
problem: str,
provider: BaseProvider,
hard_constraints: Optional[List[str]] = None,
allow_partial: bool = False,
) -> Tuple[List[Idea], Dict[str, ConvergenceDecision]]:
"""Evaluate all ideas, gate by hard constraints, cluster by similarity, and select diverse finalists."""
if not ideas:
Expand All @@ -291,6 +293,7 @@ def converge(
# One bounded request per batch, preserving each candidate's identity.
pending = [idea for idea in ideas if not idea.evaluations]
errors = []
self.last_failures = []
for offset in range(0, len(pending), 8):
batch = pending[offset : offset + 8]
payload = [
Expand Down Expand Up @@ -326,9 +329,16 @@ def converge(
"Distinguish scores and state uncertainty; do not invent supporting evidence.\n"
"CANDIDATES_JSON:\n" + json.dumps(payload)
)
response = provider.generate_for(
"evaluation_batch", prompt, json_mode=True, temperature=0.3
)
try:
response = provider.generate_for(
"evaluation_batch", prompt, json_mode=True, temperature=0.3
)
except ProviderError as error:
if not allow_partial:
raise
self.last_failures.append(error.failure)
errors.append("evaluation provider failure")
break
data = response.extract_json() or {}
evaluations = data.get("evaluations", {})
if not isinstance(evaluations, dict):
Expand All @@ -352,9 +362,22 @@ def converge(
)
except (KeyError, TypeError, ValueError):
errors.append("incomplete evaluation")
if errors:
if errors and not allow_partial:
raise ValueError("incomplete batch evaluation; valid scores retained, none fabricated")

if errors:
# Only evaluated survivors are eligible. No missing score is replaced by zero.
if not self.last_failures:
self.last_failures.append(
{
"category": "INVALID_PROVIDER_OUTPUT",
"sanitized_message": "partial evaluation coverage",
}
)
ideas = [idea for idea in ideas if idea.evaluations]
if not ideas:
return [], {}

# 2. Hard constraint evaluation: partition ideas into eligible and ineligible
for idea in ideas:
violations = self.evaluate_hard_constraints(idea, active_constraints)
Expand Down
Loading
Loading