Compare commits

...

2 Commits

Author SHA1 Message Date
63295ab80e pre-review fixes 2026-07-25 16:42:39 -05:00
e030d0d4f3 local LLM support 2026-07-25 16:17:15 -05:00
11 changed files with 193 additions and 105 deletions

View File

@@ -46,6 +46,7 @@ tests/ # pytest, uses seeded_repo fixture from conftest.py
## Key patterns ## Key patterns
- **Guardrails are emergent, not authored.** Entities never reference each other by name. Each declares only its own intrinsic physical envelope (`requires`/`provides`/`excludes` for categorical fit, `range_min`/`range_max` for numeric floors/ceilings on shared keys like `mass`, `footprint`, `energy_density`). `ConstraintResolver` cross-checks these generically across every pair in a combo — a nuclear reactor is blocked from a bicycle because `mass range_min` collides with `mass range_max`, not because any code says "nuclear + bicycle = blocked." When adding physical realism to an entity, add a property to the entity — never a pairwise special case in `ConstraintResolver`. A category missing a floor it should have is a silent guardrail hole (blocks nothing, looks fine). See `LOGIC DOCS/002-guardrails-via-intrinsic-properties.md`.
- **Repository is the only DB interface.** No raw SQL outside `repository.py`. - **Repository is the only DB interface.** No raw SQL outside `repository.py`.
- **Pipeline is combo-first**: each combo goes through all requested passes before the next combo starts. Progress is persisted per-combo (crash-safe, resumable). - **Pipeline is combo-first**: each combo goes through all requested passes before the next combo starts. Progress is persisted per-combo (crash-safe, resumable).
- **`pipeline_runs` table** tracks run lifecycle: pending → running → completed/failed/cancelled. The web route creates the record, then starts a background thread with its own `sqlite3.Connection`. - **`pipeline_runs` table** tracks run lifecycle: pending → running → completed/failed/cancelled. The web route creates the record, then starts a background thread with its own `sqlite3.Connection`.

View File

@@ -0,0 +1,37 @@
# Guardrails via intrinsic properties, not special cases
## The goal
Full recombination across the existing catalog — any platform × any actuator × any energy source — should stay possible, while physically impossible pairings (a nuclear reactor on a bicycle, a solar sail in an atmosphere) get blocked automatically. Neither half is negotiable: too loose and the results are nonsense, too special-cased and the combinatorics stop being the point of the project.
## The mechanism (already in place)
Entities never reference each other. Each one only declares its own physical envelope:
- `requires`/`provides`/`excludes` — categorical compatibility (`energy_form`, `medium`, `atmosphere`)
- `range_min`/`range_max` — numeric floors and ceilings on a shared key (`mass`, `footprint`, `energy_density`, ...)
`ConstraintResolver` (`src/physcom/engine/constraint_resolver.py`) then runs generic, entity-agnostic rules across every pair in a combo: requires-vs-excludes, mutual exclusion, range incompatibility, provides-vs-range deficit, unmet requirements. A combo gets blocked because two numbers or two category tags collided — never because code somewhere says "if X and Y, block." Blocking is emergent from the data, not authored per pair.
This is what makes "nuclear reactor won't fit on a bicycle" work today: Nuclear Thermal Drive declares `mass range_min = 1500kg`, Light Personal Vehicle declares `mass range_max = 60kg`, and Rule 3 (`_check_range_incompatibility`) blocks the combo without either entity knowing the other exists.
## Why this breaks silently
The mechanism only blocks what the data describes. A category missing a `mass range_min` or `footprint range_min` it should physically have doesn't get blocked — not because the rule is wrong, but because nothing told the resolver there was a limit. This is an easy failure mode: the constraint system looks like it's working (it blocks the pairs someone thought to test) while quietly admitting nonsense pairs nobody happened to check.
Two such gaps were found and fixed 2026-07 (see git history around this doc's commit):
- Solar Sail declared its size under the key `surface_area` instead of `footprint` — a naming mismatch made it invisible to every cross-check, silently a no-op.
- Nuclear Thermal Drive and Nuclear Fuel had a `mass range_min` but no `footprint range_min` — reactor mass was guarded, reactor size was not.
Both were fixed by adding/renaming a `Dependency`, zero changes to `ConstraintResolver` itself. That's the intended shape of a fix here.
## The standing question for review
For every actuator and energy_storage entity: what is the **full set of intrinsic physical floors and ceilings** a real version of this technology imposes at any scale — structural mass, footprint, radiation shielding, minimum coolant/containment volume, thermal rejection surface area, etc. — and is each one expressed as a `range_min`/`range_max`/`provides`/`requires` dependency using a key that **already exists** in the vocabulary (`grep -oP 'Dependency\("[a-z]+", "\K[a-z_]+'` over `seed/transport_example.py` lists it), so it actually cross-checks against every other entity that shares that key?
A missing floor is a silent guardrail hole, not a missing detail.
## Non-goal
Do not add pairwise special cases (`if actuator == "Nuclear Thermal Drive" and platform == "..."`). That defeats the architecture — the whole point is that the catalog recombines freely and physics falls out of shared, generic constraints. Any new realism belongs on the entity as a property, not in `ConstraintResolver` as a rule.

View File

@@ -227,19 +227,17 @@ def review(ctx, combination_id):
@main.command() @main.command()
@click.argument("domain_name") @click.argument("domain_name")
@click.option("--format", "fmt", default="md", help="Export format (md)")
@click.option("--top", "-n", default=20, type=int, help="Number of results to export") @click.option("--top", "-n", default=20, type=int, help="Number of results to export")
@click.option("--output", "-o", default=None, help="Output file path") @click.option("--output", "-o", default=None, help="Output file path")
@click.pass_context @click.pass_context
def export(ctx, domain_name, fmt, top, output): def export(ctx, domain_name, top, output):
"""Export results to a report.""" """Export results to a Markdown report."""
repo = _get_repo(ctx.obj["db"]) repo = _get_repo(ctx.obj["db"])
top_results = repo.get_top_results(domain_name, limit=top) top_results = repo.get_top_results(domain_name, limit=top)
if not top_results: if not top_results:
click.echo(f"No results for domain '{domain_name}'.") click.echo(f"No results for domain '{domain_name}'.")
return return
if fmt == "md":
lines = [f"# {domain_name} — Top {len(top_results)} Concepts\n"] lines = [f"# {domain_name} — Top {len(top_results)} Concepts\n"]
for i, r in enumerate(top_results, 1): for i, r in enumerate(top_results, 1):
combo = r["combination"] combo = r["combination"]
@@ -259,8 +257,6 @@ def export(ctx, domain_name, fmt, top, output):
click.echo(f"Exported to {output}") click.echo(f"Exported to {output}")
else: else:
click.echo(content) click.echo(content)
else:
click.echo(f"Unsupported format: {fmt}")
@main.group() @main.group()

View File

@@ -268,8 +268,8 @@ class Repository:
by_key.setdefault(r["key"], []).append(r["value"]) by_key.setdefault(r["key"], []).append(r["value"])
return [DomainConstraint(key=k, allowed_values=v) for k, v in by_key.items()] return [DomainConstraint(key=k, allowed_values=v) for k, v in by_key.items()]
def get_domain(self, name: str) -> Domain | None: def _load_domain(self, where: str, param: str | int) -> Domain | None:
row = self.conn.execute("SELECT * FROM domains WHERE name = ?", (name,)).fetchone() row = self.conn.execute(f"SELECT * FROM domains WHERE {where} = ?", (param,)).fetchone()
if not row: if not row:
return None return None
weights = self.conn.execute( weights = self.conn.execute(
@@ -296,37 +296,15 @@ class Repository:
constraints=self._load_domain_constraints(row["id"]), constraints=self._load_domain_constraints(row["id"]),
) )
def get_domain(self, name: str) -> Domain | None:
return self._load_domain("name", name)
def list_domains(self) -> list[Domain]: def list_domains(self) -> list[Domain]:
rows = self.conn.execute("SELECT name FROM domains ORDER BY name").fetchall() rows = self.conn.execute("SELECT name FROM domains ORDER BY name").fetchall()
return [self.get_domain(r["name"]) for r in rows] return [self.get_domain(r["name"]) for r in rows]
def get_domain_by_id(self, domain_id: int) -> Domain | None: def get_domain_by_id(self, domain_id: int) -> Domain | None:
row = self.conn.execute("SELECT * FROM domains WHERE id = ?", (domain_id,)).fetchone() return self._load_domain("id", domain_id)
if not row:
return None
weights = self.conn.execute(
"""SELECT m.name, m.unit, dmw.weight, dmw.norm_min, dmw.norm_max,
dmw.metric_id, dmw.lower_is_better
FROM domain_metric_weights dmw
JOIN metrics m ON dmw.metric_id = m.id
WHERE dmw.domain_id = ?""",
(row["id"],),
).fetchall()
return Domain(
id=row["id"],
name=row["name"],
description=row["description"] or "",
metric_bounds=[
MetricBound(
metric_name=w["name"], weight=w["weight"],
norm_min=w["norm_min"], norm_max=w["norm_max"],
metric_id=w["metric_id"], unit=w["unit"] or "",
lower_is_better=bool(w["lower_is_better"]),
)
for w in weights
],
constraints=self._load_domain_constraints(row["id"]),
)
def update_domain(self, domain_id: int, name: str, description: str) -> None: def update_domain(self, domain_id: int, name: str, description: str) -> None:
self.conn.execute( self.conn.execute(

View File

@@ -0,0 +1,73 @@
"""Ollama LLM provider — local models via the Ollama HTTP API."""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from physcom.llm.base import LLMProvider
from physcom.llm.prompts import PHYSICS_ESTIMATION_PROMPT, PLAUSIBILITY_REVIEW_PROMPT
class OllamaLLMProvider(LLMProvider):
"""LLM provider backed by a local Ollama server (see `ollama serve`)."""
def __init__(self, model: str = "qwen2.5:7b", host: str = "http://localhost:11434") -> None:
self._model = model
self._host = host.rstrip("/")
def estimate_physics(
self, combination_description: str, metrics: list[str]
) -> dict[str, float]:
prompt = PHYSICS_ESTIMATION_PROMPT.format(
description=combination_description,
metrics=", ".join(metrics),
)
text = self._generate(prompt, json_mode=True)
return self._parse_json(text, metrics)
def review_plausibility(
self, combination_description: str, scores: dict[str, float]
) -> tuple[str, bool]:
scores_str = "\n".join(f"- {k}: {v:.3f}" for k, v in scores.items())
prompt = PLAUSIBILITY_REVIEW_PROMPT.format(
description=combination_description,
scores=scores_str,
)
text = self._generate(prompt, json_mode=False).strip()
return (text, self._parse_verdict(text))
def _generate(self, prompt: str, json_mode: bool) -> str:
payload = {"model": self._model, "prompt": prompt, "stream": False}
if json_mode:
payload["format"] = "json"
req = urllib.request.Request(
f"{self._host}/api/generate",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return json.loads(resp.read())["response"]
except urllib.error.URLError as exc:
raise ConnectionError(
f"Could not reach Ollama at {self._host} (is `ollama serve` running?)"
) from exc
def _parse_verdict(self, text: str) -> bool:
"""Extract VERDICT: PLAUSIBLE/IMPLAUSIBLE from response; default to True."""
m = re.search(r"VERDICT:\s*(PLAUSIBLE|IMPLAUSIBLE)", text, re.IGNORECASE)
if m:
return m.group(1).upper() == "PLAUSIBLE"
return True
def _parse_json(self, text: str, metrics: list[str]) -> dict[str, float]:
"""Strip markdown fences and parse JSON; fall back to 0.5 per metric on error."""
text = re.sub(r"```(?:json)?\s*", "", text).strip().rstrip("`").strip()
try:
data = json.loads(text)
return {k: float(v) for k, v in data.items() if k in metrics}
except (json.JSONDecodeError, ValueError, TypeError):
return {m: 0.5 for m in metrics}

View File

@@ -10,9 +10,11 @@ from physcom.llm.base import LLMProvider
def build_llm_provider() -> LLMProvider | None: def build_llm_provider() -> LLMProvider | None:
"""Return an LLMProvider based on env vars, or None if not configured. """Return an LLMProvider based on env vars, or None if not configured.
LLM_PROVIDER — provider name ('gemini'; more can be added) LLM_PROVIDER — provider name ('gemini', 'ollama'; more can be added)
GEMINI_API_KEY — required when LLM_PROVIDER=gemini GEMINI_API_KEY — required when LLM_PROVIDER=gemini
GEMINI_MODEL — optional Gemini model name (default: gemini-2.0-flash) GEMINI_MODEL — optional Gemini model name (default: gemini-2.0-flash)
OLLAMA_MODEL — optional Ollama model name (default: qwen2.5:7b)
OLLAMA_HOST — optional Ollama server URL (default: http://localhost:11434)
""" """
provider = os.environ.get("LLM_PROVIDER", "").lower().strip() provider = os.environ.get("LLM_PROVIDER", "").lower().strip()
@@ -27,4 +29,10 @@ def build_llm_provider() -> LLMProvider | None:
from physcom.llm.providers.gemini import GeminiLLMProvider from physcom.llm.providers.gemini import GeminiLLMProvider
return GeminiLLMProvider(api_key=api_key, model=model) return GeminiLLMProvider(api_key=api_key, model=model)
raise ValueError(f"Unknown LLM_PROVIDER: {provider!r}. Supported: gemini") if provider == "ollama":
model = os.environ.get("OLLAMA_MODEL", "qwen2.5:7b")
host = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
from physcom.llm.providers.ollama import OllamaLLMProvider
return OllamaLLMProvider(model=model, host=host)
raise ValueError(f"Unknown LLM_PROVIDER: {provider!r}. Supported: gemini, ollama")

View File

@@ -338,7 +338,7 @@ RENEWABLE_ACTUATORS: list[Entity] = [
Dependency("energy", "energy_form", "radiation_pressure", None, "requires"), Dependency("energy", "energy_form", "radiation_pressure", None, "requires"),
Dependency("environment", "atmosphere", "vacuum_or_thin", None, "requires"), Dependency("environment", "atmosphere", "vacuum_or_thin", None, "requires"),
Dependency("environment", "star_proximity", "true", None, "requires"), Dependency("environment", "star_proximity", "true", None, "requires"),
Dependency("physical", "surface_area", "100", "", "range_min"), Dependency("physical", "footprint", "100", "", "range_min"),
Dependency("force", "thrust_profile", "continuous_low", None, "provides"), Dependency("force", "thrust_profile", "continuous_low", None, "provides"),
Dependency("force", "power_density", "0.01", "W/kg", "provides"), Dependency("force", "power_density", "0.01", "W/kg", "provides"),
Dependency("environment", "medium", "space", None, "requires"), Dependency("environment", "medium", "space", None, "requires"),
@@ -393,6 +393,7 @@ ROCKET_ACTUATORS: list[Entity] = [
dependencies=[ dependencies=[
Dependency("energy", "energy_form", "nuclear_thermal", None, "requires"), Dependency("energy", "energy_form", "nuclear_thermal", None, "requires"),
Dependency("physical", "mass", "1500", "kg", "range_min"), Dependency("physical", "mass", "1500", "kg", "range_min"),
Dependency("physical", "footprint", "20", "", "range_min"),
Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"), Dependency("force", "thrust_profile", "extreme_continuous", None, "provides"),
Dependency("force", "power_density", "50", "W/kg", "provides"), Dependency("force", "power_density", "50", "W/kg", "provides"),
Dependency("material", "radiation_shielding", "true", None, "requires"), Dependency("material", "radiation_shielding", "true", None, "requires"),
@@ -644,6 +645,7 @@ PROPELLANT_STORAGE: list[Entity] = [
Dependency("energy", "energy_form", "nuclear_thermal", None, "provides"), Dependency("energy", "energy_form", "nuclear_thermal", None, "provides"),
Dependency("infrastructure", "fuel_infrastructure", "nuclear_fuel", None, "requires"), Dependency("infrastructure", "fuel_infrastructure", "nuclear_fuel", None, "requires"),
Dependency("physical", "mass", "500", "kg", "range_min"), Dependency("physical", "mass", "500", "kg", "range_min"),
Dependency("physical", "footprint", "5", "", "range_min"),
Dependency("physical", "energy_density", "1800000000", "J/kg", "provides"), Dependency("physical", "energy_density", "1800000000", "J/kg", "provides"),
Dependency("material", "radiation_shielding", "true", None, "requires"), Dependency("material", "radiation_shielding", "true", None, "requires"),
], ],

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import math
import os import os
import secrets import secrets
from pathlib import Path from pathlib import Path
@@ -55,53 +54,10 @@ def close_db(exc: BaseException | None = None) -> None:
repo.conn.close() repo.conn.close()
_SI_PREFIXES = [
(1e12, "T"),
(1e9, "G"),
(1e6, "M"),
(1e3, "k"),
]
def _si_format(value: object) -> str:
"""Format a number with SI prefixes for readability.
Handles string inputs (like dep.value) by trying float conversion first.
Non-numeric values are returned as-is.
"""
if isinstance(value, str):
try:
num = float(value)
except (ValueError, TypeError):
return value
elif isinstance(value, (int, float)):
num = float(value)
else:
return str(value)
if math.isnan(num) or math.isinf(num):
return str(value)
abs_num = abs(num)
if abs_num < 1000:
# Small numbers: drop trailing zeros, cap at 4 significant figures
if num == int(num) and abs_num < 100:
return str(int(num))
return f"{num:.4g}"
for threshold, prefix in _SI_PREFIXES:
if abs_num >= threshold:
scaled = num / threshold
return f"{scaled:.4g}{prefix}"
return f"{num:.4g}"
def create_app() -> Flask: def create_app() -> Flask:
app = Flask(__name__) app = Flask(__name__)
app.secret_key = _load_or_generate_secret_key() app.secret_key = _load_or_generate_secret_key()
app.jinja_env.filters["si"] = _si_format
app.jinja_env.filters["qty"] = format_quantity app.jinja_env.filters["qty"] = format_quantity
app.teardown_appcontext(close_db) app.teardown_appcontext(close_db)

View File

@@ -59,12 +59,6 @@ def entity_detail(entity_id: int):
return render_template("entities/detail.html", entity=entity) return render_template("entities/detail.html", entity=entity)
@bp.route("/<int:entity_id>/edit")
def entity_edit(entity_id: int):
"""Legacy route — redirect to detail page."""
return redirect(url_for("entities.entity_detail", entity_id=entity_id))
@bp.route("/<int:entity_id>/delete", methods=["POST"]) @bp.route("/<int:entity_id>/delete", methods=["POST"])
def entity_delete(entity_id: int): def entity_delete(entity_id: int):
repo = get_repo() repo = get_repo()

View File

@@ -31,7 +31,7 @@
<td>{{ e.description }}</td> <td>{{ e.description }}</td>
<td>{{ e.dependencies|length }}</td> <td>{{ e.dependencies|length }}</td>
<td> <td>
<a href="{{ url_for('entities.entity_edit', entity_id=e.id) }}" class="btn btn-sm">Edit</a> <a href="{{ url_for('entities.entity_detail', entity_id=e.id) }}" class="btn btn-sm">Edit</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -0,0 +1,43 @@
"""Tests for the Ollama provider's parsing logic and registry wiring."""
from __future__ import annotations
import pytest
from physcom.llm.providers.ollama import OllamaLLMProvider
@pytest.fixture
def provider():
return OllamaLLMProvider()
def test_parse_json_strips_fences(provider):
text = '```json\n{"power_density": 500.0, "safety": 0.7}\n```'
result = provider._parse_json(text, ["power_density", "safety"])
assert result == {"power_density": 500.0, "safety": 0.7}
def test_parse_json_falls_back_on_invalid(provider):
result = provider._parse_json("not json", ["power_density", "safety"])
assert result == {"power_density": 0.5, "safety": 0.5}
def test_parse_verdict_plausible(provider):
assert provider._parse_verdict("blah blah\nVERDICT: PLAUSIBLE") is True
def test_parse_verdict_implausible(provider):
assert provider._parse_verdict("blah blah\nVERDICT: IMPLAUSIBLE") is False
def test_registry_builds_ollama_provider(monkeypatch):
from physcom.llm.registry import build_llm_provider
monkeypatch.setenv("LLM_PROVIDER", "ollama")
monkeypatch.setenv("OLLAMA_MODEL", "phi4:14b")
monkeypatch.setenv("OLLAMA_HOST", "http://example:1234")
provider = build_llm_provider()
assert isinstance(provider, OllamaLLMProvider)
assert provider._model == "phi4:14b"
assert provider._host == "http://example:1234"