{
"cells": [
{
"cell_type": "markdown",
"id": "06e7f13d",
"metadata": {},
"source": [
"# S3E Tutorial: Teaching Images to Speak PDDL\n",
"\n",
"\n",
"
\n",
"\n",
"\n",
"Welcome in. In this notebook, you are going to teach a vision-language pipeline to look at an image, answer planning-relevant questions about it, and hand back a symbolic state that a planner can actually use. That is the core idea behind S3E. This package was originally built to support the [S3E paper](https://openreview.net/forum?id=gw4hYNFUIC), but the code has since been redesigned to be modular and reusable for a variety of applications.\n",
"\n",
"We will start with the friendliest possible version of the workflow, where everything is deterministic and easy to inspect. Then, once the moving parts make sense, we will swap in a real small HuggingFace model and watch the same pipeline run for real.\n",
"\n",
"By the end, you should have a feel for both the *shape* of the library and the *rhythm* of using it."
]
},
{
"cell_type": "markdown",
"id": "5117977a",
"metadata": {},
"source": [
"## Package Overview\n",
"\n",
"Here is the whole game in one sentence: S3E turns *what a planner cares about* into *questions a model can answer*, then turns the answers back into planner-friendly state. We call this **Semantic Symbolic State Estimation** (S3E).\n",
"\n",
"The package is built as concentric layers, and each one is usable on its own:\n",
"\n",
"1. **Backends** (`s3e.backends`): a uniform `VLMBackend` interface over HuggingFace, OpenAI, and vLLM models, all returning a `VLMOutput`.\n",
"2. **Engine** (`s3e.engine`): `QueryEngine` takes images, free-form queries, and an *answer space* (`BinaryAnswers`, `CategoricalAnswers`) and returns `Prediction`s. No PDDL anywhere.\n",
"3. **Calibration** (`s3e.calibration`): collect scores on labeled examples once, then fit and apply a `PlattCalibrator` offline, without a model in the loop.\n",
"4. **State-estimation facade** (`s3e.estimator`): `SemanticStateEstimator` takes grounded predicates (an explicit list, or grounded from a PDDL domain and problem by `from_pddl`), translates them into queries, and drives a `QueryEngine`.\n",
"\n",
"If you only remember a handful of names from the repo, make them these: `SemanticStateEstimator`, `QueryEngine`, `TemplateTranslator`, `VLMBackend`, and `PredictionSet`. Everything else in the tutorial will orbit around those."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "ffde6577",
"metadata": {
"execution": {
"iopub.execute_input": "2026-08-30T14:09:28.946186Z",
"iopub.status.busy": "2026-08-30T14:09:28.945951Z",
"iopub.status.idle": "2026-08-30T14:09:29.957303Z",
"shell.execute_reply": "2026-08-30T14:09:29.956111Z"
},
"tags": [
"skip-execution"
]
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Note: you may need to restart the kernel to use updated packages.\n"
]
}
],
"source": [
"%pip install -q \"s3e[pddl,hf,calibration]\""
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "28a4623c",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:14:53.455741Z",
"iopub.status.busy": "2026-10-07T07:14:53.455119Z",
"iopub.status.idle": "2026-10-07T07:15:32.852465Z",
"shell.execute_reply": "2026-10-07T07:15:32.849926Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Repository root: /path/to/s3e\n"
]
}
],
"source": [
"import os\n",
"import sys\n",
"import tempfile\n",
"import textwrap\n",
"import warnings\n",
"from pathlib import Path\n",
"from pprint import pprint\n",
"from unittest.mock import patch\n",
"\n",
"from PIL import Image, ImageDraw\n",
"\n",
"warnings.filterwarnings(\"ignore\", message=\".*IProgress not found.*\")\n",
"from tqdm import TqdmWarning\n",
"warnings.filterwarnings(\"ignore\", category=TqdmWarning)\n",
"\n",
"\n",
"def find_repo_root(start: Path) -> Path:\n",
" for candidate in [start, *start.parents]:\n",
" if (candidate / \"pyproject.toml\").exists() and (candidate / \"s3e\").exists():\n",
" return candidate\n",
" return start\n",
"\n",
"\n",
"REPO_ROOT = find_repo_root(Path.cwd())\n",
"if str(REPO_ROOT) not in sys.path:\n",
" sys.path.insert(0, str(REPO_ROOT))\n",
"\n",
"from s3e import (\n",
" BinaryAnswers,\n",
" CalibrationExample,\n",
" CalibrationSet,\n",
" CategoricalAnswers,\n",
" HuggingFaceVLM,\n",
" IdentityTranslator,\n",
" LLMTranslator,\n",
" PlattCalibrator,\n",
" PredictionSet,\n",
" PrewrittenTranslator,\n",
" QueryEngine,\n",
" SemanticStateEstimator,\n",
" TemplateTranslator,\n",
" VLMBackend,\n",
" VLMOutput,\n",
")\n",
"from s3e.pddl import (\n",
" bool_constant_to_fnode,\n",
" convert_state_dict_to_up_compatible,\n",
" get_object_names_dict,\n",
" get_pddl_strings,\n",
" ground_predicate_str_to_fnode,\n",
" ground_predicates,\n",
" parse_domain_problem,\n",
")\n",
"from s3e.translation.cache import load_cache, make_cache_key, save_cache\n",
"\n",
"COLOR_BLOCKSWORLD_DOMAIN = \"\"\"\n",
"(define (domain color-blocksworld)\n",
" (:requirements :typing)\n",
" (:types block)\n",
" (:predicates\n",
" (on ?x - block ?y - block)\n",
" (clear ?x - block)\n",
" )\n",
")\n",
"\"\"\"\n",
"\n",
"COLOR_BLOCKSWORLD_PROBLEM = \"\"\"\n",
"(define (problem color-bw-2)\n",
" (:domain color-blocksworld)\n",
" (:objects blue orange - block)\n",
" (:init (on blue orange) (clear blue))\n",
" (:goal (on orange blue))\n",
")\n",
"\"\"\"\n",
"\n",
"COLOR_BLOCKSWORLD_PROBLEM_3 = \"\"\"\n",
"(define (problem color-bw-3)\n",
" (:domain color-blocksworld)\n",
" (:objects blue orange green - block)\n",
" (:init (on blue orange) (clear blue) (clear green))\n",
" (:goal (on green blue))\n",
")\n",
"\"\"\"\n",
"\n",
"\n",
"def make_color_blocksworld_image() -> Image.Image:\n",
" image = Image.new(\"RGB\", (256, 256), \"white\")\n",
" draw = ImageDraw.Draw(image)\n",
" draw.rectangle((78, 60, 178, 130), fill=\"deepskyblue\", outline=\"black\", width=3)\n",
" draw.rectangle((78, 130, 178, 200), fill=\"orange\", outline=\"black\", width=3)\n",
" return image\n",
"\n",
"\n",
"def make_angled_blocksworld_image() -> Image.Image:\n",
" image = Image.new(\"RGB\", (256, 256), \"white\")\n",
" draw = ImageDraw.Draw(image)\n",
"\n",
" # Bottom orange block seen from a slight right-side angle.\n",
" draw.polygon([(78, 130), (178, 130), (208, 110), (108, 110)], fill=\"#ffcc80\", outline=\"black\")\n",
" draw.polygon([(178, 130), (208, 110), (208, 180), (178, 200)], fill=\"#d9822b\", outline=\"black\")\n",
" draw.polygon([(78, 130), (178, 130), (178, 200), (78, 200)], fill=\"orange\", outline=\"black\")\n",
"\n",
" # Top blue block from the same camera angle.\n",
" draw.polygon([(110, 60), (210, 60), (240, 40), (140, 40)], fill=\"#b3e5ff\", outline=\"black\")\n",
" draw.polygon([(210, 60), (240, 40), (240, 110), (210, 130)], fill=\"#2a99c9\", outline=\"black\")\n",
" draw.polygon([(110, 60), (210, 60), (210, 130), (110, 130)], fill=\"deepskyblue\", outline=\"black\")\n",
" return image\n",
"\n",
"\n",
"TEMPLATE_MAP = {\n",
" \"on\": \"Is the {0} block on top of the {1} block?\",\n",
" \"clear\": \"Is the {0} block clear?\",\n",
"}\n",
"\n",
"\n",
"def rounded(mapping: dict, digits: int = 4) -> dict:\n",
" \"\"\"Display helper: round probabilities to keep printed output compact.\"\"\"\n",
" return {key: round(value, digits) for key, value in mapping.items()}\n",
"\n",
"\n",
"print(f\"Repository root: {REPO_ROOT}\")"
]
},
{
"cell_type": "markdown",
"id": "021378f3",
"metadata": {},
"source": [
"## 1. Start from a planning problem\n",
"\n",
"Before we ask a model anything, we need to tell it what world it lives in. In S3E, that world comes from a PDDL domain and problem.\n",
"\n",
"For this tutorial, we are keeping the world tiny on purpose: two colored blocks, where `blue` sits on `orange`, and `blue` is clear. Small enough to understand instantly, but rich enough to exercise the full pipeline.\n",
"\n",
"The `s3e.pddl` helpers parse the PDDL with [Unified Planning](https://github.com/aiplan4eu/unified-planning) and enumerate every grounded predicate over the problem's objects. This is exactly what `SemanticStateEstimator.from_pddl` does for you later, so it is worth seeing once in the open."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "78efdebf",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:32.858271Z",
"iopub.status.busy": "2026-10-07T07:15:32.857639Z",
"iopub.status.idle": "2026-10-07T07:15:33.419762Z",
"shell.execute_reply": "2026-10-07T07:15:33.417712Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Objects grouped by type:\n",
"{'block': ['blue', 'orange']}\n",
"\n",
"All grounded predicates that the estimator will consider:\n",
"['on(blue,blue)',\n",
" 'on(blue,orange)',\n",
" 'on(orange,blue)',\n",
" 'on(orange,orange)',\n",
" 'clear(blue)',\n",
" 'clear(orange)']\n"
]
}
],
"source": [
"up_problem = parse_domain_problem(COLOR_BLOCKSWORLD_DOMAIN, COLOR_BLOCKSWORLD_PROBLEM)\n",
"grounded_predicates = ground_predicates(up_problem)\n",
"\n",
"assert up_problem.name == \"color-bw-2\"\n",
"assert len(grounded_predicates) == 6\n",
"\n",
"print(\"Objects grouped by type:\")\n",
"pprint(get_object_names_dict(up_problem))\n",
"print()\n",
"print(\"All grounded predicates that the estimator will consider:\")\n",
"pprint(grounded_predicates)"
]
},
{
"cell_type": "markdown",
"id": "44ac09e4",
"metadata": {},
"source": [
"That same helper also accepts real `.pddl` files, which is nice because it lets you prototype with strings and then graduate to normal project files without rewriting the rest of your code."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "82838ed1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.424208Z",
"iopub.status.busy": "2026-10-07T07:15:33.424003Z",
"iopub.status.idle": "2026-10-07T07:15:33.453094Z",
"shell.execute_reply": "2026-10-07T07:15:33.450676Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"String input and file-path input both parsed successfully.\n"
]
}
],
"source": [
"with tempfile.TemporaryDirectory() as tmpdir:\n",
" domain_path = Path(tmpdir) / \"domain.pddl\"\n",
" problem_path = Path(tmpdir) / \"problem.pddl\"\n",
" domain_path.write_text(textwrap.dedent(COLOR_BLOCKSWORLD_DOMAIN).strip() + \"\\n\")\n",
" problem_path.write_text(textwrap.dedent(COLOR_BLOCKSWORLD_PROBLEM).strip() + \"\\n\")\n",
"\n",
" up_problem_from_files = parse_domain_problem(str(domain_path), str(problem_path))\n",
"\n",
"assert up_problem_from_files.name == up_problem.name\n",
"print(\"String input and file-path input both parsed successfully.\")"
]
},
{
"cell_type": "markdown",
"id": "0669ff9b",
"metadata": {},
"source": [
"## 2. Create an image observation\n",
"\n",
"Now let's give the estimator something to look at. In a real system, this image might come from a robot camera, a simulator, or a dataset. Here we draw the scene directly so the notebook stays self-contained and easy to replay.\n",
"\n",
"We will make two views of the same stack: one straight-on, and one sketched from a slight right-side angle."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "605bd6ef",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.457517Z",
"iopub.status.busy": "2026-10-07T07:15:33.457291Z",
"iopub.status.idle": "2026-10-07T07:15:33.487955Z",
"shell.execute_reply": "2026-10-07T07:15:33.485867Z"
}
},
"outputs": [
{
"data": {
"image/jpeg": "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",
"image/png": "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",
"text/plain": [
""
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"scene_image = make_color_blocksworld_image()\n",
"scene_image"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "6b0c3481",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.492382Z",
"iopub.status.busy": "2026-10-07T07:15:33.492191Z",
"iopub.status.idle": "2026-10-07T07:15:33.500917Z",
"shell.execute_reply": "2026-10-07T07:15:33.498890Z"
}
},
"outputs": [
{
"data": {
"image/jpeg": "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Kmh/+C6H/wCJo/4QTwf/ANCpof8A4Lof/iaP9WP+nv8A5L/wQ/tf+5+P/AONorsv+EE8H/8AQqaH/wCC6H/4mj/hBPB//QqaH/4Lof8A4mj/AFY/6e/+S/8ABD+1/wC5+P8AwDjaK7L/AIQTwf8A9Cpof/guh/8AiaP+EE8H/wDQqaH/AOC6H/4mj/Vj/p7/AOS/8EP7X/ufj/wDyS0aS48T63YWNvJe37XSyLawMu/Z9nhyx3EBV7bmIGcDOSAfR/D/AICjtJor/W5Y7y9jdZIYotywW5HPQn96wbkOwGNqlVQ5z1Gm6TpujW7W+l6faWMDOXaO1hWJS2AMkKAM4AGfYVcr3sLgKdBLq0kr+iscFfGVKumyCiiiu45AooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigD/9k=",
"image/png": "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",
"text/plain": [
""
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"angled_scene_image = make_angled_blocksworld_image()\n",
"angled_scene_image"
]
},
{
"cell_type": "markdown",
"id": "4bc57157",
"metadata": {},
"source": [
"## 3. The PDDL-free core: a `QueryEngine` and a deterministic backend\n",
"\n",
"Let's put training wheels on first. Instead of jumping straight into a real model, we will use a tiny custom `VLMBackend` that already knows the answer for this toy scene, and drive it with a bare `QueryEngine`: no PDDL, no predicates, just images and questions.\n",
"\n",
"That buys us two things immediately: the tutorial becomes perfectly runnable, and you get to see the exact contract a custom backend has to satisfy. If you ever want to plug in your own model wrapper later, this is the seam you will use.\n",
"\n",
"A backend implements one method, `query(images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs)`, and returns a `VLMOutput`. Two arguments deserve attention:\n",
"\n",
"- `generate`: `False` means \"score the next token\" (`token_probs`), `True` means \"write an answer\" (`text`).\n",
"- `interest_tokens`: when the engine scores by log-probabilities, it tells the backend exactly which token strings it cares about (the answer space's tokens). The backend then reports probability mass for *those strings only*, and flags in `argmax_in_interest` whether the model's single most likely token was one of them. Real backends use this to skip decoding the rest of the vocabulary."
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "a43f4737",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.505428Z",
"iopub.status.busy": "2026-10-07T07:15:33.505224Z",
"iopub.status.idle": "2026-10-07T07:15:33.514998Z",
"shell.execute_reply": "2026-10-07T07:15:33.513047Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Prompt the backend actually received:\n",
" Answer yes or no only: Is the blue block on top of the orange block?\n",
"Interest tokens the engine asked for:\n",
" ['yes', ' yes', 'Yes', ' Yes', 'YES', ' YES', 'no', ' no', 'No', ' No', 'NO', ' NO']\n",
"\n",
"One Prediction, piece by piece:\n",
" masses: {'yes': 0.85, 'no': 0.15000000000000002}\n",
" null_mass: 0.0\n",
" unassigned_mass: 0.0\n",
" probability: 0.85\n",
" answer: True\n",
"\n",
"Whole PredictionSet as probabilities:\n",
"{'Is the blue block clear?': 0.85,\n",
" 'Is the blue block on top of the orange block?': 0.85,\n",
" 'Is the orange block on top of the blue block?': 0.15}\n",
"\n",
"...and thresholded into a state:\n",
"{'Is the blue block clear?': True,\n",
" 'Is the blue block on top of the orange block?': True,\n",
" 'Is the orange block on top of the blue block?': False}\n"
]
}
],
"source": [
"def binary_output(yes_prob, interest_tokens=None, yes=\"yes\", no=\"no\"):\n",
" \"\"\"Build a VLMOutput that spends `yes_prob` on the yes token, the rest on no.\"\"\"\n",
" probs = {yes: yes_prob, no: 1.0 - yes_prob}\n",
" if interest_tokens is None:\n",
" return VLMOutput(token_probs=probs)\n",
" # The interest_tokens contract: exactly those keys, unknown strings get 0.0.\n",
" return VLMOutput(\n",
" token_probs={token: probs.get(token, 0.0) for token in interest_tokens},\n",
" argmax_in_interest=True,\n",
" )\n",
"\n",
"\n",
"class TutorialSceneVLM(VLMBackend):\n",
" \"\"\"A tiny deterministic backend for the tutorial notebook.\"\"\"\n",
"\n",
" def __init__(self):\n",
" self.received_prompts = []\n",
" self.received_system_prompts = []\n",
"\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" self.received_prompts.append(prompt)\n",
" self.received_system_prompts.append(system_prompt)\n",
"\n",
" truth_map = {\n",
" \"Is the blue block on top of the orange block?\": 0.85,\n",
" \"Is the orange block on top of the blue block?\": 0.15,\n",
" \"Is the blue block clear?\": 0.85,\n",
" \"Is the orange block clear?\": 0.15,\n",
" }\n",
"\n",
" yes_prob = 0.15\n",
" for query_text, probability in truth_map.items():\n",
" if query_text in prompt:\n",
" yes_prob = probability\n",
" break\n",
"\n",
" if generate:\n",
" return VLMOutput(token_probs=None, text=\"yes\" if yes_prob >= 0.5 else \"no\")\n",
" return binary_output(yes_prob, interest_tokens)\n",
"\n",
"\n",
"tutorial_vlm = TutorialSceneVLM()\n",
"engine = QueryEngine(\n",
" tutorial_vlm,\n",
" prompt_template=\"Answer yes or no only: {query}\",\n",
" system_prompt=\"You are looking at a stack of colored blocks.\",\n",
")\n",
"\n",
"questions = [\n",
" \"Is the blue block on top of the orange block?\",\n",
" \"Is the orange block on top of the blue block?\",\n",
" \"Is the blue block clear?\",\n",
"]\n",
"predictions = engine.ask([scene_image], questions)\n",
"\n",
"assert isinstance(predictions, PredictionSet)\n",
"assert list(predictions) == questions\n",
"\n",
"first = predictions[\"Is the blue block on top of the orange block?\"]\n",
"print(\"Prompt the backend actually received:\")\n",
"print(\" \", tutorial_vlm.received_prompts[0])\n",
"print(\"Interest tokens the engine asked for:\")\n",
"print(\" \", engine.answers.interest_tokens)\n",
"print()\n",
"print(\"One Prediction, piece by piece:\")\n",
"print(\" masses: \", first.masses)\n",
"print(\" null_mass: \", first.null_mass)\n",
"print(\" unassigned_mass: \", first.unassigned_mass)\n",
"print(\" probability: \", first.probability)\n",
"print(\" answer: \", first.answer)\n",
"print()\n",
"print(\"Whole PredictionSet as probabilities:\")\n",
"pprint(rounded(predictions.probabilities()))\n",
"print()\n",
"print(\"...and thresholded into a state:\")\n",
"pprint(predictions.to_state())"
]
},
{
"cell_type": "markdown",
"id": "168e3afc",
"metadata": {},
"source": [
"A few things to notice. `engine.ask` returned a `PredictionSet`, an ordered mapping from query to `Prediction`. Each `Prediction` stores only the raw data (per-option `masses`, the mass on an explicit null option, and whatever `unassigned_mass` the model spent elsewhere); everything else (`probability`, `answer`, `has_answer`, `null_dominated`, `matched`, `distribution()`, `score`) is derived lazily and cached, so nothing re-runs inference. `probability` is simply `T / (T + F)`, the true mass over the true and false masses, so it prints as a clean `0.85`; the `rounded` helper from the setup cell only keeps longer numbers short.\n",
"\n",
"Also notice the interest tokens: `BinaryAnswers()` defaults to the labels `yes`/`no`, and each label auto-expands into case and leading-space variants (`yes`, ` yes`, `Yes`, ...). That is what lets real tokenizers, which love a leading space, work without hand-written token lists."
]
},
{
"cell_type": "markdown",
"id": "4d403b16",
"metadata": {},
"source": [
"## 4. Answer spaces: yes/no is just the default\n",
"\n",
"An **answer space** says what counts as an answer and how to score the model's output. `BinaryAnswers` has boolean semantics (a true label and a false label). `CategoricalAnswers` scores any number of labeled options. Both accept an optional explicit null option (`null_tokens=...`) so a model can abstain.\n",
"\n",
"Models do not always answer in the vocabulary you hoped for, either. Sometimes `yes` and `no` are great; sometimes a different pair of words is easier to elicit. Below, the same symbolic task is answered with `present`/`absent`, and a second backend answers a categorical question about color."
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "9b35a25b",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.519453Z",
"iopub.status.busy": "2026-10-07T07:15:33.519224Z",
"iopub.status.idle": "2026-10-07T07:15:33.537317Z",
"shell.execute_reply": "2026-10-07T07:15:33.535075Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"present/absent answer space, logprobs scoring:\n",
"{'Is the blue block clear?': 0.9,\n",
" 'Is the blue block on top of the orange block?': 0.9,\n",
" 'Is the orange block on top of the blue block?': 0.1}\n",
"\n",
"The same backend and answer space, text_match scoring:\n",
"{'Is the blue block clear?': 1.0,\n",
" 'Is the blue block on top of the orange block?': 1.0,\n",
" 'Is the orange block on top of the blue block?': 0.0}\n",
"\n",
"Categorical answer: blue\n",
"Categorical distribution: {'blue': 0.7, 'orange': 0.2, 'green': 0.1}\n"
]
}
],
"source": [
"class PresentAbsentVLM(VLMBackend):\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" is_true = \"blue block on top of the orange\" in prompt or \"blue block clear\" in prompt\n",
" if generate:\n",
" return VLMOutput(token_probs=None, text=\"present\" if is_true else \"absent\")\n",
" return binary_output(0.9 if is_true else 0.1, interest_tokens, yes=\"present\", no=\"absent\")\n",
"\n",
"\n",
"class TopBlockColorVLM(VLMBackend):\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" probs = {\"blue\": 0.7, \"orange\": 0.2, \"green\": 0.1}\n",
" if generate:\n",
" return VLMOutput(token_probs=None, text=\"I think it is blue.\")\n",
" return VLMOutput(\n",
" token_probs={token: probs.get(token, 0.0) for token in interest_tokens},\n",
" argmax_in_interest=True,\n",
" )\n",
"\n",
"\n",
"present_absent = QueryEngine(\n",
" PresentAbsentVLM(),\n",
" answers=BinaryAnswers(\"present\", \"absent\"),\n",
" prompt_template=\"Answer present or absent only: {query}\",\n",
")\n",
"present_absent_predictions = present_absent.ask([scene_image], questions)\n",
"\n",
"color_engine = QueryEngine(\n",
" TopBlockColorVLM(),\n",
" answers=CategoricalAnswers([\"blue\", \"orange\", \"green\"]),\n",
")\n",
"color_prediction = color_engine.ask([scene_image], [\"What color is the top block?\"])[\"What color is the top block?\"]\n",
"\n",
"# scoring is a per-call knob too: text_match asks the backend to generate\n",
"# text (generate=True) and scores the answer option the reply starts with.\n",
"text_match_predictions = present_absent.ask([scene_image], questions, scoring=\"text_match\")\n",
"\n",
"assert present_absent_predictions.to_state()[\"Is the blue block on top of the orange block?\"] is True\n",
"assert color_prediction.answer == \"blue\"\n",
"\n",
"print(\"present/absent answer space, logprobs scoring:\")\n",
"pprint(rounded(present_absent_predictions.probabilities()))\n",
"print()\n",
"print(\"The same backend and answer space, text_match scoring:\")\n",
"pprint(rounded(text_match_predictions.probabilities()))\n",
"print()\n",
"print(\"Categorical answer:\", color_prediction.answer)\n",
"print(\"Categorical distribution:\", color_prediction.distribution())"
]
},
{
"cell_type": "markdown",
"id": "f2e376f8",
"metadata": {},
"source": [
"Here is the practical takeaway: use `logprobs` when the backend is actually surfacing the answer tokens you care about, and switch to `text_match` when you can strongly coerce the model into a short, exact answer. Neither is universally better; the right choice depends on how your model behaves. Under `text_match`, the matched option gets all the mass, so probabilities collapse to exactly `0.0`/`1.0`.\n",
"\n",
"`text_match` is strict on purpose: the reply has to *start* with one of the answer space's tokens, as a whole word (case-sensitive against the token variants; surrounding whitespace is ignored). `present`, `Absent.` and `PRESENT` all match; `I think it is present`, `Answer: present` and `**present**` match nothing, so prompt for a bare answer. A query whose reply matches nothing, or whose top answer is the null option, has **no answer** (`has_answer` is `False`): its `probability` is exactly `0.5`, its `distribution()` is uniform, and its `answer` falls to the first option. The engine also emits one `UnmatchedAnswerWarning` per call that had unmatched replies, so they never slip by silently.\n",
"\n",
"`probability` and `score` are only defined for binary answer spaces; a categorical `Prediction` gives you `answer` (the argmax label) and `distribution()` instead."
]
},
{
"cell_type": "markdown",
"id": "d2821837",
"metadata": {},
"source": [
"## 5. Build the estimator: predicates in, state out\n",
"\n",
"Now let's put the PDDL back. `SemanticStateEstimator.from_pddl` grounds the domain and problem into predicates (section 1), runs them through a *translator* to produce queries, and wires up a `QueryEngine` (section 3) underneath. The estimator's contract is \"predicates in, state out\".\n",
"\n",
"`TemplateTranslator` is the comfortable default here. It turns planner-shaped strings like `on(blue,orange)` into questions a model has a fighting chance of answering sensibly. In practice, that is usually what you want."
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "a39ab8ad",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.541466Z",
"iopub.status.busy": "2026-10-07T07:15:33.541279Z",
"iopub.status.idle": "2026-10-07T07:15:33.821613Z",
"shell.execute_reply": "2026-10-07T07:15:33.819346Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Predicate -> query mapping:\n",
"{'clear(blue)': 'Is the blue block clear?',\n",
" 'clear(orange)': 'Is the orange block clear?',\n",
" 'on(blue,blue)': 'Is the blue block on top of the blue block?',\n",
" 'on(blue,orange)': 'Is the blue block on top of the orange block?',\n",
" 'on(orange,blue)': 'Is the orange block on top of the blue block?',\n",
" 'on(orange,orange)': 'Is the orange block on top of the orange block?'}\n",
"\n",
"System prompt handed to the engine:\n",
"A curious human is asking an artificial intelligence assistant yes or no questions. The assistant answers with one of two responses: YES or NO. The assistant's response should not include any additional text.\n",
"Additional Instructions and clarifications:\n",
"Focus only on the visible colored blocks.\n"
]
}
],
"source": [
"tutorial_vlm = TutorialSceneVLM()\n",
"estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=tutorial_vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" prompt_template=\"Answer yes or no only: {query}\",\n",
" additional_instructions=\"Focus only on the visible colored blocks.\",\n",
")\n",
"\n",
"assert estimator.predicates == grounded_predicates\n",
"assert isinstance(estimator.engine, QueryEngine)\n",
"\n",
"print(\"Predicate -> query mapping:\")\n",
"pprint(estimator.queries)\n",
"print()\n",
"print(\"System prompt handed to the engine:\")\n",
"print(estimator.engine.system_prompt)"
]
},
{
"cell_type": "markdown",
"id": "635e3a3d",
"metadata": {},
"source": [
"### The three views of one prediction pass\n",
"\n",
"This is the part of the library you will touch most often, so it is worth slowing down for a second. There are three ways to look at the same prediction pass, depending on how much uncertainty and debugging detail you want to keep around.\n",
"\n",
"- Call the estimator directly for a clean symbolic state.\n",
"- Call `estimate()` for a `PredictionSet` with full per-predicate detail; `probabilities()` and `to_state()` derive from it.\n",
"- Pass `keep_raw=True` to `estimate()` when you are tuning prompts or sanity-checking a backend, and inspect the normalized `VLMOutput` on each prediction's `.raw`.\n",
"\n",
"`confidence` is an acceptance threshold on `P(true)`: a predicate is `True` when `P(true) >= confidence` and `False` otherwise, so you always get a complete state. At the default `0.5` every predicate with an answer is decided by its argmax; raise the bar and a predicate has to earn its `True`, so anything the model is not sure enough about comes back `False`. A prediction with no answer (section 4) sits at exactly `P(true) = 0.5`: `True` at the default, `False` at any stricter threshold. The state alone cannot tell \"confidently false\" from \"not confident enough\"; for that, keep the `PredictionSet` and look at `probabilities()`, or filter with `results.where(lambda p: p.confident(0.9))`. (`confidence` applies to binary answer spaces only.)"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "00d679fb",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.826396Z",
"iopub.status.busy": "2026-10-07T07:15:33.826169Z",
"iopub.status.idle": "2026-10-07T07:15:33.835826Z",
"shell.execute_reply": "2026-10-07T07:15:33.833846Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Boolean state at the default confidence=0.5:\n",
"{'clear(blue)': True,\n",
" 'clear(orange)': False,\n",
" 'on(blue,blue)': False,\n",
" 'on(blue,orange)': True,\n",
" 'on(orange,blue)': False,\n",
" 'on(orange,orange)': False}\n",
"\n",
"Probabilities:\n",
"{'clear(blue)': 0.85,\n",
" 'clear(orange)': 0.15,\n",
" 'on(blue,blue)': 0.15,\n",
" 'on(blue,orange)': 0.85,\n",
" 'on(orange,blue)': 0.15,\n",
" 'on(orange,orange)': 0.15}\n",
"\n",
"The same predictions at a stricter confidence=0.9 (0.85 does not reach 0.9, so nothing is True):\n",
"{'clear(blue)': False,\n",
" 'clear(orange)': False,\n",
" 'on(blue,blue)': False,\n",
" 'on(blue,orange)': False,\n",
" 'on(orange,blue)': False,\n",
" 'on(orange,orange)': False}\n",
"\n",
"Predicates the model is not 90% sure of, either way: 6 of 6\n",
"\n",
"Raw backend output for on(blue,orange):\n",
"VLMOutput(token_probs={'yes': 0.85, ' yes': 0.0, 'Yes': 0.0, ' Yes': 0.0, 'YES': 0.0, ' YES': 0.0, 'no': 0.15000000000000002, ' no': 0.0, 'No': 0.0, ' No': 0.0, 'NO': 0.0, ' NO': 0.0}, text=None, argmax_in_interest=True, reasoning=None)\n"
]
}
],
"source": [
"state = estimator([scene_image])\n",
"results = estimator.estimate([scene_image], keep_raw=True)\n",
"probabilities = results.probabilities()\n",
"strict_state = results.to_state(confidence=0.9)\n",
"unsure = results.where(lambda prediction: not prediction.confident(0.9))\n",
"\n",
"assert isinstance(state, dict)\n",
"assert all(isinstance(value, bool) for value in state.values())\n",
"assert all(isinstance(value, bool) for value in strict_state.values())\n",
"assert all(0.0 <= value <= 1.0 for value in probabilities.values())\n",
"assert isinstance(results[\"on(blue,orange)\"].raw, VLMOutput)\n",
"assert state[\"on(blue,orange)\"] is True\n",
"assert state[\"on(orange,blue)\"] is False\n",
"assert state[\"on(blue,blue)\"] is False\n",
"assert not any(strict_state.values()) # 0.85 does not reach 0.9\n",
"assert len(unsure) == len(results)\n",
"assert estimator([scene_image], confidence=0.9) == strict_state\n",
"\n",
"print(\"Boolean state at the default confidence=0.5:\")\n",
"pprint(state)\n",
"print()\n",
"print(\"Probabilities:\")\n",
"pprint(rounded(probabilities))\n",
"print()\n",
"print(\"The same predictions at a stricter confidence=0.9 (0.85 does not reach 0.9, so nothing is True):\")\n",
"pprint(strict_state)\n",
"print()\n",
"print(f\"Predicates the model is not 90% sure of, either way: {len(unsure)} of {len(results)}\")\n",
"print()\n",
"print(\"Raw backend output for on(blue,orange):\")\n",
"print(results[\"on(blue,orange)\"].raw)"
]
},
{
"cell_type": "markdown",
"id": "1febe6df",
"metadata": {},
"source": [
"A good mental shortcut is this: the boolean state is just the probability state after someone has drawn a line in the sand. If your downstream code can benefit from uncertainty, hold onto the `PredictionSet` for as long as you can.\n",
"\n",
"Predictions are also plain data. `PredictionSet.to_dict()` / `PredictionSet.from_dict()` round-trip through JSON with no backend involved, which is how you keep prediction details around for offline analysis or recalibration later."
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "6ef017d8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.840348Z",
"iopub.status.busy": "2026-10-07T07:15:33.840152Z",
"iopub.status.idle": "2026-10-07T07:15:33.856372Z",
"shell.execute_reply": "2026-10-07T07:15:33.854298Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Serialized 6 predictions into 2902 bytes of JSON and back.\n",
"Restored on(blue,orange) masses: {'yes': 0.85, 'no': 0.15000000000000002}\n"
]
}
],
"source": [
"import json\n",
"\n",
"payload = json.dumps(results.to_dict())\n",
"restored = PredictionSet.from_dict(json.loads(payload))\n",
"\n",
"assert restored.probabilities() == results.probabilities()\n",
"assert restored[\"on(blue,orange)\"].raw is None # raw backend output is never serialized\n",
"\n",
"print(f\"Serialized {len(results)} predictions into {len(payload)} bytes of JSON and back.\")\n",
"print(\"Restored on(blue,orange) masses:\", restored[\"on(blue,orange)\"].masses)"
]
},
{
"cell_type": "markdown",
"id": "df9be76a",
"metadata": {},
"source": [
"### Querying a subset of predicates\n",
"\n",
"Sometimes you do not need every grounded predicate evaluated. Maybe you already know most of the state and only need to re-check a few fluents, or maybe your domain is large and you want to save on VLM calls. `estimate()` accepts an optional `predicates` argument that restricts which grounded predicates are queried. Pass `None` (the default) to query everything, or pass a list of predicate strings to query only those."
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "0dfda69e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.860745Z",
"iopub.status.busy": "2026-10-07T07:15:33.860570Z",
"iopub.status.idle": "2026-10-07T07:15:33.876892Z",
"shell.execute_reply": "2026-10-07T07:15:33.875007Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Filtered boolean state (2 of 6 predicates):\n",
"{'clear(blue)': True, 'on(blue,orange)': True}\n",
"\n",
"Filtered probabilities:\n",
"{'clear(blue)': 0.85, 'on(blue,orange)': 0.85}\n",
"\n",
"Filtered raw output:\n",
" on(blue,orange): VLMOutput(token_probs={'yes': 0.85, ' yes': 0.0, 'Yes': 0.0, ' Yes': 0.0, 'YES': 0.0, ' YES': 0.0, 'no': 0.15000000000000002, ' no': 0.0, 'No': 0.0, ' No': 0.0, 'NO': 0.0, ' NO': 0.0}, text=None, argmax_in_interest=True, reasoning=None)\n",
" clear(blue): VLMOutput(token_probs={'yes': 0.85, ' yes': 0.0, 'Yes': 0.0, ' Yes': 0.0, 'YES': 0.0, ' YES': 0.0, 'no': 0.15000000000000002, ' no': 0.0, 'No': 0.0, ' No': 0.0, 'NO': 0.0, ' NO': 0.0}, text=None, argmax_in_interest=True, reasoning=None)\n"
]
}
],
"source": [
"subset = [\"on(blue,orange)\", \"clear(blue)\"]\n",
"\n",
"filtered = estimator.estimate([scene_image], predicates=subset, keep_raw=True)\n",
"\n",
"assert list(filtered) == subset\n",
"\n",
"print(\"Filtered boolean state (2 of 6 predicates):\")\n",
"pprint(filtered.to_state())\n",
"print()\n",
"print(\"Filtered probabilities:\")\n",
"pprint(rounded(filtered.probabilities()))\n",
"print()\n",
"print(\"Filtered raw output:\")\n",
"for predicate, prediction in filtered.items():\n",
" print(f\" {predicate}: {prediction.raw}\")"
]
},
{
"cell_type": "markdown",
"id": "c724eb37",
"metadata": {},
"source": [
"## 6. Calibrating probabilities with held-out examples\n",
"\n",
"Raw model probabilities are often useful, but they are not automatically trustworthy. A model that says `0.8` does not necessarily mean \"80% of the time this fluent is true.\" S3E can fit a small post-hoc Platt scaling layer on held-out examples so that downstream thresholds and planning decisions operate on better-calibrated numbers.\n",
"\n",
"Calibration is a self-contained pipeline over prediction data, and it separates the expensive step from the cheap one:\n",
"\n",
"1. `CalibrationSet.collect(estimator, examples)` queries the VLM once per labeled example and records a grouped log-odds `score` plus the true `label` for every predicate the model answered (a prediction with no answer is skipped: its probability stays `0.5` whatever a calibrator says, so it is not a training point). This is the only step that needs a model.\n",
"2. `PlattCalibrator.fit(data, scope=...)` fits the sigmoid. `scope` controls grouping: `\"global\"` (one fit for everything), `\"lifted\"` (one per predicate name, so every `on(...)` shares a fit), or `\"grounded\"` (one per fully-grounded predicate).\n",
"3. `calibrator.apply(results)` returns a new, calibrated `PredictionSet`; `estimator.estimate(images, calibrator=...)` does the same inline.\n",
"\n",
"Both the data and the fitted calibrator save to JSON, so you can refit with a different scope tomorrow without touching the model again. Fitting requires the optional extra (`pip install 's3e[calibration]'`) and `scoring=\"logprobs\"`."
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "3d2f7d8a",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:33.881767Z",
"iopub.status.busy": "2026-10-07T07:15:33.881522Z",
"iopub.status.idle": "2026-10-07T07:15:34.085976Z",
"shell.execute_reply": "2026-10-07T07:15:34.084718Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"First collected sample: CalibrationSample(predicate='clear(blue)', score=1.386294361116141, label=True, problem=None)\n",
"Global calibrator groups: ['__global__']\n",
"Lifted calibrator groups: ['clear', 'on']\n",
"\n",
"Raw probabilities:\n",
"{'clear(blue)': 0.7,\n",
" 'clear(orange)': 0.3,\n",
" 'on(blue,blue)': 0.05,\n",
" 'on(blue,orange)': 0.6,\n",
" 'on(orange,blue)': 0.2,\n",
" 'on(orange,orange)': 0.05}\n",
"\n",
"Global Platt-scaled probabilities:\n",
"{'clear(blue)': 0.8376448556124211,\n",
" 'clear(orange)': 0.2222486486029375,\n",
" 'on(blue,blue)': 0.007896955077302354,\n",
" 'on(blue,orange)': 0.7081478869691008,\n",
" 'on(orange,blue)': 0.10220937327764033,\n",
" 'on(orange,orange)': 0.007896955077302354}\n",
"\n",
"Lifted-fluent Platt-scaled probabilities:\n",
"{'clear(blue)': 0.7886829110663371,\n",
" 'clear(orange)': 0.34560451632924843,\n",
" 'on(blue,blue)': 0.01447525743604522,\n",
" 'on(blue,orange)': 0.6115560324656291,\n",
" 'on(orange,blue)': 0.1144130324303443,\n",
" 'on(orange,orange)': 0.01447525743604522}\n"
]
}
],
"source": [
"def calibration_prompt(predicate: str) -> str:\n",
" name, arguments = predicate[:-1].split(\"(\", 1)\n",
" return TEMPLATE_MAP[name].format(*arguments.split(\",\"))\n",
"\n",
"\n",
"CALIBRATION_LABELS_2OBJ = {\n",
" \"clear(blue)\": True,\n",
" \"clear(orange)\": False,\n",
" \"on(blue,blue)\": False,\n",
" \"on(blue,orange)\": True,\n",
" \"on(orange,blue)\": False,\n",
" \"on(orange,orange)\": False,\n",
"}\n",
"\n",
"CALIBRATION_LABELS_3OBJ = {\n",
" \"clear(blue)\": True,\n",
" \"clear(green)\": True,\n",
" \"clear(orange)\": False,\n",
" \"on(blue,blue)\": False,\n",
" \"on(blue,green)\": False,\n",
" \"on(blue,orange)\": True,\n",
" \"on(green,blue)\": False,\n",
" \"on(green,green)\": False,\n",
" \"on(green,orange)\": False,\n",
" \"on(orange,blue)\": False,\n",
" \"on(orange,green)\": False,\n",
" \"on(orange,orange)\": False,\n",
"}\n",
"\n",
"CALIBRATION_SCORES = {\n",
" example_id: {\n",
" calibration_prompt(predicate): score\n",
" for predicate, score in predicate_scores.items()\n",
" }\n",
" for example_id, predicate_scores in {\n",
" 1: {\n",
" \"clear(blue)\": 0.80,\n",
" \"clear(orange)\": 0.20,\n",
" \"on(blue,blue)\": 0.05,\n",
" \"on(blue,orange)\": 0.70,\n",
" \"on(orange,blue)\": 0.20,\n",
" \"on(orange,orange)\": 0.05,\n",
" },\n",
" 2: {\n",
" \"clear(blue)\": 0.75,\n",
" \"clear(green)\": 0.70,\n",
" \"clear(orange)\": 0.25,\n",
" \"on(blue,blue)\": 0.05,\n",
" \"on(blue,green)\": 0.25,\n",
" \"on(blue,orange)\": 0.65,\n",
" \"on(green,blue)\": 0.10,\n",
" \"on(green,green)\": 0.05,\n",
" \"on(green,orange)\": 0.15,\n",
" \"on(orange,blue)\": 0.15,\n",
" \"on(orange,green)\": 0.10,\n",
" \"on(orange,orange)\": 0.05,\n",
" },\n",
" 3: {\n",
" \"clear(blue)\": 0.70,\n",
" \"clear(orange)\": 0.30,\n",
" \"on(blue,blue)\": 0.05,\n",
" \"on(blue,orange)\": 0.60,\n",
" \"on(orange,blue)\": 0.20,\n",
" \"on(orange,orange)\": 0.05,\n",
" },\n",
" }.items()\n",
"}\n",
"\n",
"\n",
"class CalibrationTutorialVLM(VLMBackend):\n",
" \"\"\"Reads an example id out of the image and answers from a score table.\"\"\"\n",
"\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" example_id = images[0].getpixel((0, 0))[0]\n",
" return binary_output(CALIBRATION_SCORES[example_id][prompt], interest_tokens)\n",
"\n",
"\n",
"def calibration_demo_image(example_id: int):\n",
" return [Image.new(\"RGB\", (2, 2), color=(example_id, 0, 0))]\n",
"\n",
"\n",
"calibration_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=CalibrationTutorialVLM(),\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
")\n",
"\n",
"held_out_examples = [\n",
" CalibrationExample(\n",
" images=calibration_demo_image(1),\n",
" state_dict=CALIBRATION_LABELS_2OBJ,\n",
" ),\n",
" CalibrationExample(\n",
" images=calibration_demo_image(2),\n",
" state_dict=CALIBRATION_LABELS_3OBJ,\n",
" problem=COLOR_BLOCKSWORLD_PROBLEM_3, # a different problem instance, same domain\n",
" ),\n",
"]\n",
"\n",
"with tempfile.TemporaryDirectory() as tmpdir:\n",
" # Expensive step: query the VLM once per example, then save the scores.\n",
" data = CalibrationSet.collect(calibration_estimator, held_out_examples)\n",
" data.save(Path(tmpdir) / \"calibration-data.json\")\n",
"\n",
" # Cheap and VLM-free from here on.\n",
" data = CalibrationSet.load(Path(tmpdir) / \"calibration-data.json\")\n",
" global_calibrator = PlattCalibrator.fit(data, scope=\"global\")\n",
" lifted_calibrator = PlattCalibrator.fit(data, scope=\"lifted\")\n",
" lifted_calibrator.save(Path(tmpdir) / \"platt-lifted.json\")\n",
" lifted_calibrator = PlattCalibrator.load(Path(tmpdir) / \"platt-lifted.json\")\n",
"\n",
"assert len(data.samples) == 18\n",
"assert calibration_estimator.predicates == grounded_predicates # restored after collect()\n",
"\n",
"raw_results = calibration_estimator.estimate(calibration_demo_image(3))\n",
"global_results = global_calibrator.apply(raw_results)\n",
"lifted_results = calibration_estimator.estimate(calibration_demo_image(3), calibrator=lifted_calibrator)\n",
"\n",
"print(\"First collected sample:\", data.samples[0])\n",
"print(\"Global calibrator groups:\", global_calibrator.group_keys())\n",
"print(\"Lifted calibrator groups:\", lifted_calibrator.group_keys())\n",
"print()\n",
"print(\"Raw probabilities:\")\n",
"pprint(rounded(raw_results.probabilities()))\n",
"print()\n",
"print(\"Global Platt-scaled probabilities:\")\n",
"pprint(global_results.probabilities())\n",
"print()\n",
"print(\"Lifted-fluent Platt-scaled probabilities:\")\n",
"pprint(lifted_results.probabilities())"
]
},
{
"cell_type": "markdown",
"id": "ca5cf1aa",
"metadata": {},
"source": [
"Two details are easy to miss. First, the second example carried its own `problem`: `CalibrationSet.collect` re-grounded the estimator against it while collecting and restored the original problem afterwards, which is how you pool calibration data across problem instances of one domain. Second, a calibrated `PredictionSet` still has the original `masses`; only `probability` is overridden, so you can always look back at what the model actually said."
]
},
{
"cell_type": "markdown",
"id": "763dac3a",
"metadata": {},
"source": [
"## 7. Choosing a translator strategy\n",
"\n",
"Think of translators as the bridge between planner language and model language. The planner thinks in predicates. The model would usually prefer a question. The translator is the part that keeps those two worlds from talking past each other.\n",
"\n",
"The practical rule of thumb is simple: start with `TemplateTranslator`, drop to `IdentityTranslator` only when literal predicates are what you want, reach for `PrewrittenTranslator` when every prompt needs careful hand-authoring, and use `LLMTranslator` when you want the library to draft those prompts for you.\n",
"\n",
"One subtle but important detail: translator choice also changes the estimator's default answer space and system prompt. Literal predicate prompting scores `true`/`false` and, via `from_pddl`, gets a domain-aware system prompt that spells out the PDDL domain and objects; natural-language prompting scores `yes`/`no`."
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "705f2a07",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:34.098703Z",
"iopub.status.busy": "2026-10-07T07:15:34.098071Z",
"iopub.status.idle": "2026-10-07T07:15:34.214366Z",
"shell.execute_reply": "2026-10-07T07:15:34.213062Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Identity translator example:\n",
"[('on(blue,blue)', 'on(blue,blue)'), ('on(blue,orange)', 'on(blue,orange)')]\n",
"\n",
"Prewritten translator example:\n",
"[('on(blue,blue)', 'Is the blue block stacked on itself?'),\n",
" ('on(blue,orange)', 'Is the blue block on top of the orange block?')]\n",
"\n",
"Answer labels without translation: ['true', 'false']\n",
"Answer labels with translation: ['yes', 'no']\n",
"\n",
"Domain-aware system prompt for identity translation:\n",
"The following is a PDDL domain\n",
"(define (domain color-bw-2-domain)\n",
" (:requirements :strips :typing)\n",
" (:types block)\n",
" (:predicates \n",
" (on ?x - block ?y - block)\n",
" (clear ?x - block)\n",
" )\n",
")\n",
"\n",
"Here are the names of all the objects in the current problem, sorted by their type:\n",
"block type: ['blue', 'orange']\n",
"Given a grounded predicate with concrete variables, state whether the statement is true or false.\n",
"Respond only with a \"true\" or \"false\" response and nothing else.\n"
]
}
],
"source": [
"identity_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=TutorialSceneVLM(),\n",
")\n",
"\n",
"prewritten_queries = {\n",
" \"on(blue,blue)\": \"Is the blue block stacked on itself?\",\n",
" \"on(blue,orange)\": \"Is the blue block on top of the orange block?\",\n",
" \"on(orange,blue)\": \"Is the orange block on top of the blue block?\",\n",
" \"on(orange,orange)\": \"Is the orange block stacked on itself?\",\n",
" \"clear(blue)\": \"Is the blue block clear?\",\n",
" \"clear(orange)\": \"Is the orange block clear?\",\n",
"}\n",
"prewritten_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=TutorialSceneVLM(),\n",
" translator=PrewrittenTranslator(prewritten_queries),\n",
")\n",
"\n",
"assert isinstance(identity_estimator.translator, IdentityTranslator)\n",
"\n",
"print(\"Identity translator example:\")\n",
"pprint(list(identity_estimator.queries.items())[:2])\n",
"print()\n",
"print(\"Prewritten translator example:\")\n",
"pprint(list(prewritten_estimator.queries.items())[:2])\n",
"print()\n",
"print(\"Answer labels without translation:\", identity_estimator.engine.answers.labels)\n",
"print(\"Answer labels with translation: \", prewritten_estimator.engine.answers.labels)\n",
"print()\n",
"print(\"Domain-aware system prompt for identity translation:\")\n",
"print(identity_estimator.engine.system_prompt)"
]
},
{
"cell_type": "markdown",
"id": "c7833fcd",
"metadata": {},
"source": [
"### A quick note on backend kwargs\n",
"\n",
"There are two different kwargs channels, and keeping them separate will save you a lot of confusion:\n",
"\n",
"- `vlm_kwargs` are for backend construction time (model/client setup). They only apply when `vlm` is a model string; passing them alongside an already-constructed backend instance raises, rather than silently dropping them.\n",
"- `inference_kwargs` are for per-query runtime inference options. Set defaults on the estimator, and override them per call with `estimate(..., inference_kwargs=...)`.\n",
"\n",
"For custom backends, this means your `query(...)` or `query_batch(...)` implementation should accept `generate`, `interest_tokens`, and `**inference_kwargs`, and apply `inference_kwargs` during the actual model call.\n",
"\n",
"In practical terms: `scoring=\"logprobs\"` drives `generate=False` with `interest_tokens` set, while `scoring=\"text_match\"` drives `generate=True` with `interest_tokens=None`."
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "38943e7b",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:34.217302Z",
"iopub.status.busy": "2026-10-07T07:15:34.216966Z",
"iopub.status.idle": "2026-10-07T07:15:34.315014Z",
"shell.execute_reply": "2026-10-07T07:15:34.313104Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"First logprobs call (generate should be False, interest tokens set):\n",
"{'generate': False,\n",
" 'inference_kwargs': {'temperature': 0.2},\n",
" 'interest_tokens': ['yes',\n",
" ' yes',\n",
" 'Yes',\n",
" ' Yes',\n",
" 'YES',\n",
" ' YES',\n",
" 'no',\n",
" ' no',\n",
" 'No',\n",
" ' No',\n",
" 'NO',\n",
" ' NO']}\n",
"Per-call override wins over the estimator default: {'temperature': 0.0}\n",
"output: {'on(blue,blue)': 0.8, 'on(blue,orange)': 0.8, 'on(orange,blue)': 0.8, 'on(orange,orange)': 0.8, 'clear(blue)': 0.8, 'clear(orange)': 0.8}\n",
"\n",
"First text_match call (generate should be True, no interest tokens):\n",
"{'generate': True,\n",
" 'inference_kwargs': {'max_new_tokens': 4},\n",
" 'interest_tokens': None}\n",
"output: {'on(blue,blue)': 1.0, 'on(blue,orange)': 1.0, 'on(orange,blue)': 1.0, 'on(orange,orange)': 1.0, 'clear(blue)': 1.0, 'clear(orange)': 1.0}\n"
]
}
],
"source": [
"class DebugKwargsVLM(VLMBackend):\n",
" \"\"\"Tiny backend that records mode, interest tokens, and inference kwargs.\"\"\"\n",
"\n",
" def __init__(self):\n",
" self.calls = []\n",
"\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" self.calls.append({\n",
" \"generate\": generate,\n",
" \"interest_tokens\": interest_tokens,\n",
" \"inference_kwargs\": dict(inference_kwargs),\n",
" })\n",
" if generate:\n",
" return VLMOutput(token_probs=None, text=\"yes\")\n",
" return binary_output(0.8, interest_tokens)\n",
"\n",
"\n",
"logprobs_debug_vlm = DebugKwargsVLM()\n",
"logprobs_debug_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=logprobs_debug_vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" inference_kwargs={\"temperature\": 0.2},\n",
")\n",
"out_prob = logprobs_debug_estimator.estimate([scene_image]).probabilities()\n",
"_ = logprobs_debug_estimator.estimate([scene_image], inference_kwargs={\"temperature\": 0.0})\n",
"\n",
"text_match_debug_vlm = DebugKwargsVLM()\n",
"text_match_debug_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=text_match_debug_vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" scoring=\"text_match\",\n",
" inference_kwargs={\"max_new_tokens\": 4},\n",
")\n",
"out_text = text_match_debug_estimator.estimate([scene_image]).probabilities()\n",
"\n",
"assert logprobs_debug_vlm.calls[0][\"generate\"] is False\n",
"assert logprobs_debug_vlm.calls[-1][\"inference_kwargs\"] == {\"temperature\": 0.0}\n",
"assert text_match_debug_vlm.calls[0][\"generate\"] is True\n",
"\n",
"print(\"First logprobs call (generate should be False, interest tokens set):\")\n",
"pprint(logprobs_debug_vlm.calls[0])\n",
"print(\"Per-call override wins over the estimator default:\", logprobs_debug_vlm.calls[-1][\"inference_kwargs\"])\n",
"print(\"output:\", rounded(out_prob))\n",
"print()\n",
"print(\"First text_match call (generate should be True, no interest tokens):\")\n",
"pprint(text_match_debug_vlm.calls[0])\n",
"print(\"output:\", rounded(out_text))"
]
},
{
"cell_type": "markdown",
"id": "3dfe9155",
"metadata": {},
"source": [
"## 8. Multiple scenes, averaging, and batching\n",
"\n",
"This is where the cost model starts to matter. Every grounded predicate is a question, and every image is another piece of evidence, so runtime can grow faster than you expect.\n",
"\n",
"S3E gives you a few levers to manage that. A *scene* is a list of images shown to the model together in one query. To combine evidence from several scenes of the same state, `estimate_averaged(scenes)` estimates each scene separately and averages the stored masses (`PredictionSet.average` does the same for any prediction sets over the same keys). And `batch_size` controls how many predicate queries travel together in one backend call.\n",
"\n",
"The examples below use two views of the same scene: one straight-on, one from a different angle."
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "615be83c",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:34.319186Z",
"iopub.status.busy": "2026-10-07T07:15:34.318946Z",
"iopub.status.idle": "2026-10-07T07:15:34.365412Z",
"shell.execute_reply": "2026-10-07T07:15:34.363817Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Head-on view: 0.9\n",
"Angled view: 0.6\n",
"\n",
"Averaged probabilities across both views:\n",
"{'clear(blue)': 0.75,\n",
" 'clear(orange)': 0.25,\n",
" 'on(blue,blue)': 0.25,\n",
" 'on(blue,orange)': 0.75,\n",
" 'on(orange,blue)': 0.25,\n",
" 'on(orange,orange)': 0.25}\n"
]
}
],
"source": [
"class ViewAwareVLM(VLMBackend):\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" image = images[0]\n",
" angled_view = image.getpixel((225, 90)) != (255, 255, 255)\n",
" if \"blue block on top of the orange block\" in prompt or \"blue block clear\" in prompt:\n",
" yes_prob = 0.6 if angled_view else 0.9\n",
" else:\n",
" yes_prob = 0.4 if angled_view else 0.1\n",
" return binary_output(yes_prob, interest_tokens)\n",
"\n",
"\n",
"average_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=ViewAwareVLM(),\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
")\n",
"head_on = average_estimator.estimate([scene_image])\n",
"angled = average_estimator.estimate([angled_scene_image])\n",
"averaged = average_estimator.estimate_averaged([[scene_image], [angled_scene_image]])\n",
"\n",
"assert abs(averaged[\"on(blue,orange)\"].probability - 0.75) < 1e-9\n",
"assert averaged.probabilities() == PredictionSet.average([head_on, angled]).probabilities()\n",
"\n",
"print(\"Head-on view: \", round(head_on[\"on(blue,orange)\"].probability, 4))\n",
"print(\"Angled view: \", round(angled[\"on(blue,orange)\"].probability, 4))\n",
"print()\n",
"print(\"Averaged probabilities across both views:\")\n",
"pprint(rounded(averaged.probabilities()))"
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "a02a87f9",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:34.368834Z",
"iopub.status.busy": "2026-10-07T07:15:34.368655Z",
"iopub.status.idle": "2026-10-07T07:15:34.686258Z",
"shell.execute_reply": "2026-10-07T07:15:34.685267Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"With 6 grounded predicates and batch_size=2, the estimator made 3 batch calls.\n"
]
}
],
"source": [
"class BatchCountingVLM(VLMBackend):\n",
" def __init__(self):\n",
" self.batch_calls = 0\n",
"\n",
" def query(self, images, prompt, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" raise RuntimeError(\"query() should not be used when query_batch() is overridden\")\n",
"\n",
" def query_batch(self, images, prompts, system_prompt=None, generate=False, interest_tokens=None, **inference_kwargs):\n",
" self.batch_calls += 1\n",
" return [\n",
" binary_output(0.8 if \"blue block\" in prompt else 0.2, interest_tokens)\n",
" for prompt in prompts\n",
" ]\n",
"\n",
"\n",
"batch_vlm = BatchCountingVLM()\n",
"batch_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=batch_vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" batch_size=2,\n",
")\n",
"_ = batch_estimator.estimate([scene_image])\n",
"\n",
"assert batch_vlm.batch_calls == 3\n",
"print(\"With 6 grounded predicates and batch_size=2, the estimator made\", batch_vlm.batch_calls, \"batch calls.\")"
]
},
{
"cell_type": "markdown",
"id": "23b0946f",
"metadata": {},
"source": [
"### Getting the most out of HuggingFace batching\n",
"\n",
"`batch_size` controls how many prompts the `QueryEngine` sends to `vlm.query_batch(...)` at once. The toy backend above only counts those calls, but `HuggingFaceVLM` uses the batch for real work. For each batch, it renders the prompt text for every query, calls the HuggingFace processor once with `text=[...]` and the same image set for every row, then runs one batched model call.\n",
"\n",
"That applies to both scoring modes:\n",
"\n",
"- `scoring=\"logprobs\"`: one batched forward pass, then the mass on each interest token is gathered per prompt.\n",
"- `scoring=\"text_match\"`: one batched `generate(...)` call, then generated answers are decoded back into one `VLMOutput` per prompt.\n",
"\n",
"A practical HuggingFace setup usually looks like this:\n",
"\n",
"```python\n",
"hf_vlm = HuggingFaceVLM(TINY_HF_VLM_ID, device_map=device)\n",
"\n",
"hf_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=hf_vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" prompt_template=\"Answer with exactly one word, yes or no: {query}\",\n",
" batch_size=6, # all 6 predicates in this toy problem fit in one backend batch\n",
")\n",
"```\n",
"\n",
"To make batching pay off in real runs:\n",
"\n",
"- Start with a modest `batch_size` such as 4 or 8, then increase it until GPU/CPU memory becomes the bottleneck.\n",
"- If all grounded predicates fit comfortably, set `batch_size` at least as large as the predicate count so the engine can use one backend batch.\n",
"- In logprobs mode the engine asks the backend for exactly the answer space's tokens (`interest_tokens`), so the masses are exact and nothing else is detokenized. `HuggingFaceVLM(num_logprobs=...)` only matters when you query the backend directly without interest tokens: finite values return only the most likely tokens, while `None` returns all vocabulary probabilities, which is useful for deep debugging but slower.\n",
"- Do not loop over predicates manually. Let `estimate(...)` or `estimator(...)` batch the prompts for you; use `predicates=[...]` only when you intentionally want a smaller subset.\n",
"- Predicates that translate to the same query are asked once and share the prediction.\n",
"- Several images in one scene are attached to every prompt row in the batch. `estimate_averaged` runs separate estimations per scene and averages the results, which can be more robust but costs more model calls.\n",
"\n",
"The batching path is deliberately model-agnostic. It does not use KV-cache tricks or model-family-specific assumptions, and if a processor cannot handle batched multimodal inputs, the backend falls back to per-prompt processing for correctness.\n",
"\n",
"And when careful batching still is not enough -- large predicate counts, multiple GPUs going to waste -- that is your cue to jump to section 12 and let vLLM do the heavy lifting."
]
},
{
"cell_type": "markdown",
"id": "d645803a",
"metadata": {},
"source": [
"## 9. Swapping to a new planning problem\n",
"\n",
"Imagine your planner moves on to a new problem instance: same domain, different objects, different grounded predicates. You do not necessarily need to rebuild everything from scratch.\n",
"\n",
"`set_problem(...)` is the quick way to re-ground the planning problem and refresh the query set; the engine and its backend are untouched, so a loaded model stays loaded. The catch is that it leaves your prompt template and answer space alone. If those settings were generic, great. If they were tightly tied to the old problem, rebuild the estimator and save yourself a confusing afternoon."
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "c6fed627",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:34.689751Z",
"iopub.status.busy": "2026-10-07T07:15:34.689592Z",
"iopub.status.idle": "2026-10-07T07:15:34.761718Z",
"shell.execute_reply": "2026-10-07T07:15:34.760945Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Grounded predicates: 6 -> 12\n",
"A few queries after the swap:\n",
"[('on(blue,blue)', 'Is the blue block on top of the blue block?'),\n",
" ('on(blue,orange)', 'Is the blue block on top of the orange block?'),\n",
" ('on(blue,green)', 'Is the blue block on top of the green block?'),\n",
" ('on(orange,blue)', 'Is the orange block on top of the blue block?'),\n",
" ('on(orange,orange)', 'Is the orange block on top of the orange block?')]\n"
]
}
],
"source": [
"swap_demo = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=TutorialSceneVLM(),\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
")\n",
"backend_before = swap_demo.engine.backend\n",
"before_count = len(swap_demo.queries)\n",
"swap_demo.set_problem(COLOR_BLOCKSWORLD_DOMAIN, COLOR_BLOCKSWORLD_PROBLEM_3)\n",
"after_count = len(swap_demo.queries)\n",
"\n",
"assert before_count == 6\n",
"assert after_count == 12\n",
"assert swap_demo.engine.backend is backend_before\n",
"\n",
"print(f\"Grounded predicates: {before_count} -> {after_count}\")\n",
"print(\"A few queries after the swap:\")\n",
"pprint(list(swap_demo.queries.items())[:5])"
]
},
{
"cell_type": "markdown",
"id": "56b5b18d",
"metadata": {},
"source": [
"## 10. Real HuggingFace integration with a small instruct model\n",
"\n",
"Time to take the training wheels off. Everything up to this point was about learning the workflow cleanly; now we plug in a real `HuggingFaceVLM` and run the same pipeline for real.\n",
"\n",
"For this notebook, I picked `HuggingFaceTB/SmolVLM-256M-Instruct`. It is not a powerhouse, but it *does* follow simple instructions, and it is still small enough to be a reasonable CPU-first demo.\n",
"\n",
"Notice that we do not hand-write any token lists: the default `BinaryAnswers()` already covers `yes`, ` Yes`, `YES`, and friends, and the backend gathers mass at the matching vocabulary ids. `argmax_in_interest` on the raw output tells you whether the model's top token was actually one of them.\n",
"\n",
"Set expectations accordingly: this section is about integration and behavior, not about showing off world-class visual reasoning."
]
},
{
"cell_type": "code",
"execution_count": 19,
"id": "742be0d6",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:34.765228Z",
"iopub.status.busy": "2026-10-07T07:15:34.765083Z",
"iopub.status.idle": "2026-10-07T07:15:42.451796Z",
"shell.execute_reply": "2026-10-07T07:15:42.450856Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Loading the tiny HuggingFace VLM (first run may download weights)...\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Loading weights: 0%| | 0/471 [00:00, ?it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Loading weights: 35%|███▌ | 167/471 [00:00<00:00, 1639.36it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Loading weights: 85%|████████▌ | 402/471 [00:00<00:00, 2051.12it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Loading weights: 100%|██████████| 471/471 [00:00<00:00, 2035.13it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Model: HuggingFaceTB/SmolVLM-256M-Instruct\n",
"\n",
"Estimated probabilities on the toy scene:\n",
"{'clear(blue)': 0.9585390939042181,\n",
" 'clear(orange)': 0.5583786055221358,\n",
" 'on(blue,blue)': 0.9884920357956214,\n",
" 'on(blue,orange)': 0.9877587986182237,\n",
" 'on(orange,blue)': 0.9458023010219452,\n",
" 'on(orange,orange)': 0.949671530921271}\n",
"\n",
"Boolean state:\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'clear(blue)': True,\n",
" 'clear(orange)': True,\n",
" 'on(blue,blue)': True,\n",
" 'on(blue,orange)': True,\n",
" 'on(orange,blue)': True,\n",
" 'on(orange,orange)': True}\n",
"\n",
"Prediction detail for on(blue,orange):\n",
" masses: {'yes': 0.987758545136554, 'no': 0.012241198240401981}\n",
" unassigned_mass: 2.5662304403706315e-07\n",
" argmax_in_interest: True\n",
" raw token_probs:\n",
"{' NO': 4.0572100346025763e-08,\n",
" ' No': 0.012240592390298843,\n",
" ' YES': 0.0,\n",
" ' Yes': 0.9877035617828369,\n",
" ' no': 5.344326723388804e-07,\n",
" ' yes': 5.366846016841009e-05,\n",
" 'NO': 3.957583913360985e-11,\n",
" 'No': 2.8103473326268613e-08,\n",
" 'YES': 0.0,\n",
" 'Yes': 1.2820372603528085e-06,\n",
" 'no': 2.7022812876253965e-09,\n",
" 'yes': 3.2856288356697405e-08}\n"
]
}
],
"source": [
"import torch\n",
"from transformers.utils import logging as hf_logging\n",
"\n",
"TINY_HF_VLM_ID = \"HuggingFaceTB/SmolVLM-256M-Instruct\"\n",
"warnings.filterwarnings(\"ignore\", message=\".*torch_dtype.*\")\n",
"warnings.filterwarnings(\"ignore\", message=\".*fast processor.*\")\n",
"hf_logging.set_verbosity_error()\n",
"\n",
"print(\"Loading the tiny HuggingFace VLM (first run may download weights)...\")\n",
"device = (\n",
" \"cuda\" if torch.cuda.is_available() # prefer NVIDIA GPU\n",
" else \"mps\" if torch.backends.mps.is_available() # then Apple Silicon GPU\n",
" else \"cpu\" # fallback to CPU if no GPU is available\n",
")\n",
"hf_vlm = HuggingFaceVLM(TINY_HF_VLM_ID, device_map=device)\n",
"hf_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=hf_vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" prompt_template=\"Answer with exactly one word, yes or no: {query}\",\n",
" batch_size=2,\n",
")\n",
"hf_results = hf_estimator.estimate([scene_image], keep_raw=True)\n",
"hf_probabilities = hf_results.probabilities()\n",
"\n",
"assert all(0.0 <= value <= 1.0 for value in hf_probabilities.values())\n",
"assert len(hf_results) == 6\n",
"\n",
"hf_on = hf_results[\"on(blue,orange)\"]\n",
"print(\"Model:\", TINY_HF_VLM_ID)\n",
"print()\n",
"print(\"Estimated probabilities on the toy scene:\")\n",
"pprint(hf_probabilities)\n",
"print()\n",
"print(\"Boolean state:\")\n",
"pprint(hf_estimator([scene_image]))\n",
"print()\n",
"print(\"Prediction detail for on(blue,orange):\")\n",
"print(\" masses: \", hf_on.masses)\n",
"print(\" unassigned_mass: \", hf_on.unassigned_mass)\n",
"print(\" argmax_in_interest:\", hf_on.argmax_in_interest)\n",
"print(\" raw token_probs:\")\n",
"pprint(hf_on.raw.token_probs)"
]
},
{
"cell_type": "markdown",
"id": "ef6564af",
"metadata": {},
"source": [
"If you only learn one debugging habit from the real-model section, make it this one: always peek at `keep_raw=True` output, `unassigned_mass`, and `argmax_in_interest` before you over-interpret the probabilities. Small models have quirks, tokenization has quirks, and those quirks become much less mysterious once you look at the actual `VLMOutput`. A large `unassigned_mass` means the model wanted to say something other than your answer options."
]
},
{
"cell_type": "markdown",
"id": "ef6ead02",
"metadata": {},
"source": [
"## 11. Thinking models: reasoning before answering\n",
"\n",
"Some recent VLMs *think* before they answer: they write their reasoning inside a delimited block (` ... ` for the Qwen3 family) and only then reply. Some open that block by default, Qwen3.5-4B and larger through their chat template and Mistral's reasoning models through their default system prompt; others, such as Gemma 4, think when asked to.\n",
"\n",
"That collides with `scoring=\"logprobs\"`, which reads the probability of the *very next token* as the answer. When the prompt ends inside an open `` block, the next token is the first word of the reasoning, not `yes` or `no`.\n",
"\n",
"To see it happen, we will use `Qwen/Qwen3.5-0.8B` (about 1.7 GB to download). Its chat template leaves thinking off, but the template variable `enable_thinking=True` makes it behave the way its larger siblings do by default. The two renderings differ only in their last few characters:"
]
},
{
"cell_type": "code",
"execution_count": 20,
"id": "fbb94fc2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:42.456020Z",
"iopub.status.busy": "2026-10-07T07:15:42.455769Z",
"iopub.status.idle": "2026-10-07T07:15:43.387056Z",
"shell.execute_reply": "2026-10-07T07:15:43.385413Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"thinking off (default): ...'>assistant\\n\\n\\n\\n\\n'\n",
"thinking on: ...'<|im_start|>assistant\\n\\n'\n"
]
}
],
"source": [
"from transformers import AutoProcessor\n",
"\n",
"THINKING_VLM_ID = \"Qwen/Qwen3.5-0.8B\"\n",
"THINKING_ON = {\"enable_thinking\": True} # what Qwen3.5-4B and larger do by default\n",
"\n",
"thinking_processor = AutoProcessor.from_pretrained(THINKING_VLM_ID)\n",
"conversation = [\n",
" {\"role\": \"user\", \"content\": [{\"type\": \"image\"}, {\"type\": \"text\", \"text\": \"Is the blue block clear?\"}]}\n",
"]\n",
"for label, options in [(\"thinking off (default):\", {}), (\"thinking on:\", THINKING_ON)]:\n",
" rendered = thinking_processor.apply_chat_template(\n",
" conversation, add_generation_prompt=True, tokenize=False, **options\n",
" )\n",
" print(f\"{label:24} ...{rendered[-30:]!r}\")"
]
},
{
"cell_type": "markdown",
"id": "b4634330",
"metadata": {},
"source": [
"With thinking on, the prompt ends inside an open `` block; with thinking off, it carries an empty block that is already closed.\n",
"\n",
"First, what happens when the backend ignores the block. `thinking_delimiters=None` switches S3E's thinking handling off, so the backend scores the very next token, as it would for any other model. `chat_template_kwargs` passes variables to the chat template, here turning thinking on:"
]
},
{
"cell_type": "code",
"execution_count": 21,
"id": "52da595d",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:43.390890Z",
"iopub.status.busy": "2026-10-07T07:15:43.390667Z",
"iopub.status.idle": "2026-10-07T07:15:48.517029Z",
"shell.execute_reply": "2026-10-07T07:15:48.515105Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Thinking on, block ignored:\n",
" on(blue,blue) P(true) = 0.077 mass on yes/no = 2.9e-06 argmax_in_interest = False\n",
" on(blue,orange) P(true) = 0.117 mass on yes/no = 3.0e-06 argmax_in_interest = False\n",
" on(orange,blue) P(true) = 0.099 mass on yes/no = 2.5e-06 argmax_in_interest = False\n",
" on(orange,orange) P(true) = 0.099 mass on yes/no = 2.2e-06 argmax_in_interest = False\n",
" clear(blue) P(true) = 0.079 mass on yes/no = 5.5e-06 argmax_in_interest = False\n",
" clear(orange) P(true) = 0.081 mass on yes/no = 4.8e-06 argmax_in_interest = False\n"
]
}
],
"source": [
"import gc\n",
"import time\n",
"\n",
"# Four model loads ahead; keep their progress bars out of the output.\n",
"hf_logging.disable_progress_bar()\n",
"# Qwen3.5 is trained in bfloat16, which float16 (the GPU default) can overflow.\n",
"thinking_dtype = (\n",
" torch.bfloat16 if device == \"cuda\" and torch.cuda.is_bf16_supported() else torch.float32\n",
")\n",
"\n",
"\n",
"def run_thinking_estimator(predicates=None, **vlm_options):\n",
" \"\"\"Estimate the toy scene with a fresh Qwen3.5-0.8B backend, then free it.\n",
"\n",
" Returns the predictions and the seconds the estimate itself took.\n",
" \"\"\"\n",
" vlm = HuggingFaceVLM(\n",
" THINKING_VLM_ID,\n",
" device_map=device,\n",
" torch_dtype=thinking_dtype,\n",
" # Qwen3.5 answers the same padded or not; skipping the one-time check\n",
" # keeps the timings comparable.\n",
" skip_pad_invariance_check=True,\n",
" **vlm_options,\n",
" )\n",
" estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" prompt_template=\"Answer with exactly one word, yes or no: {query}\",\n",
" batch_size=6,\n",
" )\n",
" # Pay the one-time costs before timing: the first reply on the device, and\n",
" # the token index the first scored query builds over the whole vocabulary.\n",
" vlm.query_batch([scene_image], [\"Describe the image.\"] * 6, generate=True, max_new_tokens=1)\n",
" vlm.unsupported_interest_tokens([\"yes\"])\n",
" start = time.perf_counter()\n",
" results = estimator.estimate([scene_image], predicates=predicates, keep_raw=True)\n",
" seconds = time.perf_counter() - start\n",
" del vlm, estimator # free the weights before the next configuration loads\n",
" gc.collect()\n",
" if device == \"cuda\":\n",
" torch.cuda.empty_cache()\n",
" return results, seconds\n",
"\n",
"\n",
"block_ignored, _ = run_thinking_estimator(\n",
" chat_template_kwargs=THINKING_ON, thinking_delimiters=None\n",
")\n",
"\n",
"assert all(prediction.unassigned_mass > 0.9 for prediction in block_ignored.values())\n",
"\n",
"print(\"Thinking on, block ignored:\")\n",
"for predicate, prediction in block_ignored.items():\n",
" print(\n",
" f\" {predicate:18} P(true) = {prediction.probability:.3f} \"\n",
" f\"mass on yes/no = {1 - prediction.unassigned_mass:.1e} \"\n",
" f\"argmax_in_interest = {prediction.raw.argmax_in_interest}\"\n",
" )"
]
},
{
"cell_type": "markdown",
"id": "0f88a893",
"metadata": {},
"source": [
"That is a silent failure, and exactly what the `keep_raw=True` habit from section 10 is for. Almost no probability lands on `yes` or `no` (a few millionths, and `argmax_in_interest` is `False`), yet every predicate still gets a probability: `T / (T + F)` of two tiny masses, which is noise. The resulting state calls every predicate `False`, including the two that hold, and no warning fires, because the masses are tiny but not exactly zero.\n",
"\n",
"`HuggingFaceVLM` and `VLLMBackend` take a `thinking` flag instead. With the default `thinking=False`, the model answers without reasoning. Qwen3.5's template has a switch, so the backend simply turns it off: it finds it when it is constructed by rendering a probe conversation with each known template variable (`enable_thinking` here). Models without a switch get a fallback: the backend watches for a thinking block (the prompt ends by opening one, or the model's most likely next token opens one), closes it at once, and scores the answer after the empty block.\n",
"\n",
"We can watch the fallback by forcing the template's thinking on ourselves, since `chat_template_kwargs` override the switch. For Qwen3.5 the closed block is exactly the prompt its template renders with thinking off, so the two give the same answers. `VLMOutput.reasoning` records what happened: `\"\"` for a block closed without reasoning, `None` when there was no block."
]
},
{
"cell_type": "code",
"execution_count": 22,
"id": "8086165f",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:48.520720Z",
"iopub.status.busy": "2026-10-07T07:15:48.520482Z",
"iopub.status.idle": "2026-10-07T07:15:58.486804Z",
"shell.execute_reply": "2026-10-07T07:15:58.484954Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"P(true) block ignored block closed thinking off\n",
"on(blue,blue) 0.0765 0.4622 0.4622\n",
"on(blue,orange) 0.1169 0.7662 0.7662\n",
"on(orange,blue) 0.0989 0.4859 0.4859\n",
"on(orange,orange) 0.0988 0.3891 0.3891\n",
"clear(blue) 0.0792 0.6684 0.6684\n",
"clear(orange) 0.0814 0.4740 0.4740\n",
"\n",
"Seconds per predicate: thinking off 0.05, block closed 0.15\n"
]
}
],
"source": [
"# thinking=False (the default) closes the block the forced-on template opens...\n",
"closed, closed_seconds = run_thinking_estimator(chat_template_kwargs=THINKING_ON)\n",
"# ...and otherwise turns the template's switch off.\n",
"thinking_off, off_seconds = run_thinking_estimator()\n",
"\n",
"for predicate in closed:\n",
" assert abs(closed[predicate].probability - thinking_off[predicate].probability) < 1e-4\n",
"assert all(prediction.raw.reasoning == \"\" for prediction in closed.values())\n",
"assert all(prediction.raw.reasoning is None for prediction in thinking_off.values())\n",
"\n",
"print(f\"{'P(true)':18} {'block ignored':>13} {'block closed':>12} {'thinking off':>12}\")\n",
"for predicate in closed:\n",
" print(\n",
" f\"{predicate:18} {block_ignored[predicate].probability:13.4f} \"\n",
" f\"{closed[predicate].probability:12.4f} {thinking_off[predicate].probability:12.4f}\"\n",
" )\n",
"print()\n",
"print(\n",
" f\"Seconds per predicate: thinking off {off_seconds / len(thinking_off):.2f}, \"\n",
" f\"block closed {closed_seconds / len(closed):.2f}\"\n",
")"
]
},
{
"cell_type": "markdown",
"id": "774852e8",
"metadata": {},
"source": [
"### Letting the model think\n",
"\n",
"Closing the block throws the model's reasoning away. `thinking=True` keeps it: the backend turns the switch on, the model reasons first, greedily and for up to `max_thinking_tokens` tokens (1024 by default, after which the block is closed for it), and the answer is scored after its reasoning. The reasoning lands in `VLMOutput.reasoning`. (A warning says when a model offers no way to think.) We cap the budget at 256 tokens and compare the answers with the scene's true state:"
]
},
{
"cell_type": "code",
"execution_count": 23,
"id": "e74205c5",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:15:58.490408Z",
"iopub.status.busy": "2026-10-07T07:15:58.490181Z",
"iopub.status.idle": "2026-10-07T07:16:14.359054Z",
"shell.execute_reply": "2026-10-07T07:16:14.357201Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"P(true) thinking off thinking truth\n",
"on(blue,blue) 0.4622 0.0409 False\n",
"on(blue,orange) 0.7662 0.9993 True\n",
"on(orange,blue) 0.4859 0.9985 False\n",
"on(orange,orange) 0.3891 0.1019 False\n",
"clear(blue) 0.6684 0.4833 True\n",
"clear(orange) 0.4740 0.2743 False\n",
"\n",
"Thinking off: 6 of 6 predicates right at confidence 0.5\n",
"Thinking: 4 of 6 predicates right at confidence 0.5\n",
"Seconds per predicate: thinking off 0.05, block closed 0.15, thinking 1.91\n",
"\n",
"Reasoning behind on(blue,orange):\n",
" The user wants me to answer a yes/no question based on an image.\n",
"\n",
" 1. **Analyze the image:**\n",
" * There is a blue square block.\n",
" * There is an orange square block below it.\n",
" * The blue block is positioned directly on top of the orange block.\n",
"\n",
" 2. **Analyze the question:** \"Is the blue block on top of the orange block?\"\n",
"\n",
" 3. **Formulate the answer:**\n",
" * The visual evidence clearly shows the blue block is on top of the orange block.\n",
" * The user requested the answer be exactly one word: \"yes\" or \"no\".\n",
" * The answer is \"yes\".\n",
"\n",
" 4. **Final check:** Does the response incl ...\n",
"\n",
"Reasoning behind on(orange,blue):\n",
" The user wants me to answer a yes/no question based on an image.\n",
"\n",
" 1. **Analyze the image:**\n",
" * There is a blue square.\n",
" * There is an orange square.\n",
" * The orange square is positioned directly on top of the blue square.\n",
"\n",
" 2. **Analyze the question:** \"Is the orange block on top of the blue block?\"\n",
"\n",
" 3. **Formulate the answer:**\n",
" * The visual evidence shows the orange block is indeed on top of the blue block.\n",
" * The user requested the answer be exactly one word: \"yes\" or \"no\".\n",
" * The answer is \"yes\".\n",
"\n",
" 4. **Final check:** Does the response include any additional t ...\n"
]
}
],
"source": [
"thought, thought_seconds = run_thinking_estimator(thinking=True, max_thinking_tokens=256)\n",
"truth = CALIBRATION_LABELS_2OBJ # the toy scene's true state, from section 6\n",
"\n",
"assert all(prediction.raw.reasoning for prediction in thought.values())\n",
"\n",
"print(f\"{'P(true)':18} {'thinking off':>12} {'thinking':>9} {'truth':>6}\")\n",
"for predicate in thought:\n",
" print(\n",
" f\"{predicate:18} {thinking_off[predicate].probability:12.4f} \"\n",
" f\"{thought[predicate].probability:9.4f} {str(truth[predicate]):>6}\"\n",
" )\n",
"print()\n",
"for name, results in [(\"Thinking off\", thinking_off), (\"Thinking\", thought)]:\n",
" right = sum(results.to_state()[predicate] == label for predicate, label in truth.items())\n",
" print(f\"{name}: {right} of {len(truth)} predicates right at confidence 0.5\")\n",
"print(\n",
" f\"Seconds per predicate: thinking off {off_seconds / len(thinking_off):.2f}, \"\n",
" f\"block closed {closed_seconds / len(closed):.2f}, thinking {thought_seconds / len(thought):.2f}\"\n",
")\n",
"for predicate in [\"on(blue,orange)\", \"on(orange,blue)\"]:\n",
" print()\n",
" print(f\"Reasoning behind {predicate}:\")\n",
" print(textwrap.indent(thought[predicate].raw.reasoning[:600].rstrip() + \" ...\", \" \"))"
]
},
{
"cell_type": "markdown",
"id": "560b6ce5",
"metadata": {},
"source": [
"### Accuracy versus cost\n",
"\n",
"Thinking trades compute for a chance at better answers. On questions that take several steps (counting, spatial relations among many objects, partial occlusion), a model that reasons first can answer better than one that has to commit to its first token, and models trained to reason may lose accuracy when you turn their thinking off. Whether that pays off for your domain is an empirical question, and the run above shows both sides of it:\n",
"\n",
"- **Cost.** Every grounded predicate is its own question. With thinking off, each one costs a single forward pass. For a model without a switch, closing the block adds two short passes per query (a few greedy steps that find the whitespace after the block, then the scoring pass): about 3× in the run saved here, on one GPU. Thinking adds a generation of up to `max_thinking_tokens` tokens per predicate: about 40× here, and more with a larger budget or a more talkative model. Multiply that by the 110 predicates of a 10-block blocksworld, observed at every step of a plan, and thinking can dominate the run time of the whole pipeline. Batching helps (the HuggingFace backend reasons for a whole batch at once, and vLLM schedules requests continuously), but it cannot remove the extra tokens.\n",
"- **Accuracy.** In the run saved here, thinking made the model right and nearly certain about `on(blue,orange)` (0.7662 to 0.9993), but just as certain, and wrong, about `on(orange,blue)` (0.4859 to 0.9985): its reasoning describes the orange block on top. With thinking, 4 of the 6 predicates come out right at confidence 0.5; the hesitant thinking-off answers get all 6. A 0.8B model is a weak reasoner and larger models usually gain more from thinking, but the lesson holds at any size: reasoning can talk a model into a wrong answer as easily as into a right one.\n",
"- **Confidence.** After reasoning, the model has usually committed, so its probabilities sit near 0 or 1. For a planner, a confident mistake is worse than a hesitant one: a stricter `confidence` threshold no longer catches it. A calibrator fitted on held-out examples (section 6) is how you find out how often those near-certain answers are actually right.\n",
"- **Budget.** Greedy reasoning can repeat itself until the budget runs out, and an answer read after a block closed at the budget follows unfinished reasoning. A tighter budget saves time but may cut the reasoning off before it reaches a conclusion.\n",
"\n",
"A practical rule of thumb:\n",
"\n",
"1. Keep the default `thinking=False`. It turns thinking off through the template's switch where there is one, which costs nothing (and here matched closing the block exactly), and closes the block at a modest cost where there is not. Models that never think pay nothing either way.\n",
"2. Use `thinking=True` only where you have measured that it pays: collect labeled examples with thinking on and off (the `CalibrationSet` workflow from section 6 gathers exactly this data), compare their accuracy and calibration, and weigh the difference against the per-predicate cost on your hardware.\n",
"\n",
"The same arguments work on `VLLMBackend`. In text mode (`scoring=\"text_match\"`), `thinking=False` generates the reply after a closed block, and `thinking=True` cuts the block out of the reply and keeps it in `reasoning`; give the model enough `max_new_tokens` (`max_tokens` with vLLM) to finish reasoning, or the reply is `None` and the query has no answer. The README's [Thinking models](https://github.com/CLAIR-LAB-TECHNION/s3e#thinking-models) section lists the supported model families and the formats S3E does not cover."
]
},
{
"cell_type": "markdown",
"id": "6090fccd",
"metadata": {},
"source": [
"## 12. Scaling up with vLLM: many GPUs, one estimator\n",
"\n",
"Before moving on, it is worth pausing on what `estimate` actually does on a *real* problem: it asks one question per grounded predicate. Our toy scene has 6 predicates. A 10-block blocksworld already has 110, and a realistic robotics domain can reach thousands. With plain transformers, every estimator batch becomes one giant forward pass, so activation memory grows linearly with `batch_size`, and however many GPUs you have, that forward pass has a single-GPU-sized ceiling.\n",
"\n",
"This is exactly the workload [vLLM](https://docs.vllm.ai/) was built for, and S3E ships a backend for it. You construct `VLLMBackend(...)` explicitly and pass the instance as `vlm=`:\n",
"\n",
"- The backend loads the model **once** into a persistent engine and, by default, shards it across **every visible GPU** (tensor parallelism). A multi-GPU machine behaves like one big GPU; you never assign layers or devices yourself.\n",
"- Each estimator batch becomes a single `chat` call, and vLLM's continuous batching schedules it internally. You can hand it hundreds of prompts at once without doing any memory math.\n",
"\n",
"One installation note before we run it. The standard `pip install s3e[vllm]` wheel is built for CUDA GPUs; vLLM also publishes a separate [CPU build](https://docs.vllm.ai/en/latest/getting_started/installation/cpu.html) for machines without one (slower, but the same API). The demo cell asks the installed build what it can actually run on -- GPU build with a visible GPU, or CPU build -- and politely skips itself if the answer is neither. Nothing later in the notebook depends on it. If it does run, expect the first call to take a little longer: the engine may download weights and JIT-compile kernels once."
]
},
{
"cell_type": "code",
"execution_count": 24,
"id": "28620caf",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:16:14.363128Z",
"iopub.status.busy": "2026-10-07T07:16:14.362931Z",
"iopub.status.idle": "2026-10-07T07:17:27.768298Z",
"shell.execute_reply": "2026-10-07T07:17:27.766106Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"WARNING 10-07 10:16:20 [vllm.py:942] Enforce eager set, disabling torch.compile and CUDAGraphs. This is equivalent to setting -cc.mode=none -cc.cudagraph_mode=none\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"WARNING 10-07 10:16:20 [vllm.py:960] Inductor compilation was disabled by user settings, optimizations settings that are only active during inductor compilation will be ignored.\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"(EngineCore pid=4018190) [transformers] Model config: pad_token_id must be `None` or an integer within the vocabulary (between 0 and 31999), got 128002. This may result in unexpected behavior.\n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"(EngineCore pid=4018190) WARNING 10-07 10:17:13 [vllm.py:942] Enforce eager set, disabling torch.compile and CUDAGraphs. This is equivalent to setting -cc.mode=none -cc.cudagraph_mode=none\n",
"(EngineCore pid=4018190) WARNING 10-07 10:17:13 [vllm.py:960] Inductor compilation was disabled by user settings, optimizations settings that are only active during inductor compilation will be ignored.\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"(EngineCore pid=4018190) \r",
"Loading safetensors checkpoint shards: 0% Completed | 0/1 [00:00, ?it/s]\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"(EngineCore pid=4018190) \r",
"Loading safetensors checkpoint shards: 100% Completed | 1/1 [00:00<00:00, 4.73it/s]\n",
"(EngineCore pid=4018190) \r",
"Loading safetensors checkpoint shards: 100% Completed | 1/1 [00:00<00:00, 4.72it/s]\n",
"(EngineCore pid=4018190) \n"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"(EngineCore pid=4018190) WARNING 10-07 10:17:18 [vllm.py:942] Enforce eager set, disabling torch.compile and CUDAGraphs. This is equivalent to setting -cc.mode=none -cc.cudagraph_mode=none\n",
"(EngineCore pid=4018190) WARNING 10-07 10:17:18 [vllm.py:960] Inductor compilation was disabled by user settings, optimizations settings that are only active during inductor compilation will be ignored.\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Rendering conversations: 0%| | 0/6 [00:00, ?it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Rendering conversations: 17%|█▋ | 1/6 [00:03<00:15, 3.04s/it]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Rendering conversations: 100%|██████████| 6/6 [00:03<00:00, 1.96it/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Processed prompts: 0%| | 0/6 [00:00, ?it/s, est. speed input: 0.00 toks/s, output: 0.00 toks/s]"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"(EngineCore pid=4018190) WARNING 10-07 10:17:22 [jit_monitor.py:103] Triton kernel JIT compilation during inference: _compute_slot_mapping_kernel. This causes a latency spike; consider extending warmup to cover this shape/config.\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Processed prompts: 17%|█▋ | 1/6 [00:04<00:24, 4.97s/it, est. speed input: 241.18 toks/s, output: 0.20 toks/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Processed prompts: 33%|███▎ | 2/6 [00:05<00:09, 2.44s/it, est. speed input: 425.84 toks/s, output: 0.36 toks/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Processed prompts: 100%|██████████| 6/6 [00:05<00:00, 2.44s/it, est. speed input: 1275.34 toks/s, output: 1.07 toks/s]"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\r",
"Processed prompts: 100%|██████████| 6/6 [00:05<00:00, 1.06it/s, est. speed input: 1275.34 toks/s, output: 1.07 toks/s]"
]
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"Same pipeline, same model, new engine (on GPU):\n",
"{'clear(blue)': 0.9525757875214371,\n",
" 'clear(orange)': 0.5312606665794362,\n",
" 'on(blue,blue)': 0.9902916584570867,\n",
" 'on(blue,orange)': 0.9890130612706108,\n",
" 'on(orange,blue)': 0.9525748433796233,\n",
" 'on(orange,orange)': 0.9525760835641411}\n"
]
},
{
"name": "stderr",
"output_type": "stream",
"text": [
"\n"
]
}
],
"source": [
"import importlib.util\n",
"\n",
"# Keep the engine's startup logs out of the notebook; must be set before vllm is imported.\n",
"os.environ.setdefault(\"VLLM_LOGGING_LEVEL\", \"WARNING\")\n",
"\n",
"vllm_ready = False\n",
"if importlib.util.find_spec(\"vllm\") is not None:\n",
" # A plain `pip install vllm` gives you the CUDA build, but vLLM also\n",
" # publishes a CPU-only build. Rather than assume, ask the installed build\n",
" # what it can actually drive on this machine.\n",
" from vllm.platforms import current_platform\n",
"\n",
" on_gpu = current_platform.is_cuda() and torch.cuda.is_available()\n",
" on_cpu = current_platform.is_cpu()\n",
" vllm_ready = on_gpu or on_cpu\n",
"\n",
"if not vllm_ready:\n",
" print(\"Skipping the vLLM demo: it needs `pip install s3e[vllm]` plus either\")\n",
" print(\"a CUDA GPU or a CPU build of vLLM (see the vLLM installation docs).\")\n",
" print(\"The rest of the notebook does not depend on this section.\")\n",
"else:\n",
" from s3e import VLLMBackend\n",
"\n",
" # vLLM runs the model in a worker subprocess. By this point the notebook\n",
" # has already initialized CUDA (the HuggingFace section above), and CUDA\n",
" # cannot be re-initialized in a *forked* child process, so ask vLLM to\n",
" # spawn fresh worker processes instead. Harmless on CPU.\n",
" os.environ.setdefault(\"VLLM_WORKER_MULTIPROC_METHOD\", \"spawn\")\n",
"\n",
" engine_kwargs = dict(\n",
" # Our 256M demo model is too small to split across GPUs (attention\n",
" # heads must divide evenly among them) -- omit this on real models\n",
" # and the backend uses every GPU it can see.\n",
" tensor_parallel_size=1,\n",
" max_model_len=4096,\n",
" enforce_eager=True, # skip CUDA-graph capture so the demo starts faster\n",
" )\n",
" if on_gpu:\n",
" # Leave room for the HuggingFace model from section 10, which is\n",
" # still resident on the GPU. (Meaningless on the CPU build.)\n",
" engine_kwargs[\"gpu_memory_utilization\"] = 0.35\n",
"\n",
" vllm_backend = VLLMBackend(TINY_HF_VLM_ID, **engine_kwargs) # the same model string as section 10...\n",
" vllm_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=vllm_backend, # ...now routed through the vLLM engine\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" prompt_template=\"Answer with exactly one word, yes or no: {query}\",\n",
" batch_size=6, # all six predicates in one engine call\n",
" )\n",
"\n",
" vllm_probabilities = vllm_estimator.estimate([scene_image]).probabilities()\n",
" assert all(0.0 <= value <= 1.0 for value in vllm_probabilities.values())\n",
"\n",
" print(f\"Same pipeline, same model, new engine (on {'GPU' if on_gpu else 'CPU'}):\")\n",
" pprint(vllm_probabilities)"
]
},
{
"cell_type": "markdown",
"id": "c1ffb161",
"metadata": {},
"source": [
"### The multi-GPU payoff\n",
"\n",
"Notice what did *not* change in that cell: the domain, the translator, the answer space, the prompts -- the entire pipeline is untouched. Swapping engines is a construction-time decision, which is the whole point of the `VLMBackend` interface.\n",
"\n",
"The demo pinned `tensor_parallel_size=1` only because a 256M model cannot be split. With a real model you simply omit it, and the backend resolves it to the local GPU count on its own:\n",
"\n",
"```python\n",
"from s3e import SemanticStateEstimator, TemplateTranslator, VLLMBackend\n",
"\n",
"vlm = VLLMBackend(\"Qwen/Qwen3-VL-8B-Instruct\") # tensor_parallel_size defaults to ALL local GPUs\n",
"estimator = SemanticStateEstimator.from_pddl(\n",
" domain_pddl,\n",
" problem_pddl,\n",
" vlm=vlm,\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
" batch_size=512, # let the engine schedule; no manual memory math\n",
")\n",
"```\n",
"\n",
"To make the difference concrete, here is what we measured with that exact model on one machine with four 24 GB GPUs, sending the same image-plus-prompt batches through both backends:\n",
"\n",
"| batch size | `HuggingFaceVLM` (`device_map=\"auto\"`) | `VLLMBackend` (default TP) |\n",
"|---|---|---|\n",
"| 64 | 10.4s | 0.32s |\n",
"| 256 | out of memory | 0.63s |\n",
"| 512 | -- | 1.01s |\n",
"\n",
"The HuggingFace backend spreads *layers* across the GPUs, so they take turns computing and the whole batch must fit through one forward pass -- it hits the memory ceiling at 256 prompts. The vLLM engine shards every layer across all four GPUs and streams the batch through continuous batching, so 512 prompts complete in about a second and the ceiling simply never arrives at these scales.\n",
"\n",
"Two practical notes carry over from the batching section:\n",
"\n",
"- Estimator queries match the answer space's tokens by token id (`interest_tokens`), so full-vocabulary detokenization never runs. With vLLM, `num_logprobs=None` (the default) keeps those masses exact over the full vocabulary; a finite `num_logprobs` caps how many entries the engine materializes, at the cost of dropping interest-token mass ranked below the cutoff.\n",
"- With vLLM you can set `batch_size` to the full predicate count and stop thinking about it. The engine, not the estimator, decides how much runs concurrently."
]
},
{
"cell_type": "markdown",
"id": "abb8feae",
"metadata": {},
"source": [
"## 13. Auto-generating translators and caching them\n",
"\n",
"Writing prompt templates by hand is fine when your domain is tiny. It gets old quickly once the domain becomes large or awkward. That is where `LLMTranslator` becomes handy: let a language model draft the natural-language questions once, then cache them so you do not keep paying for the same translation work.\n",
"\n",
"`LLMTranslator` is the one translator that needs the PDDL context (the domain text and the problem's objects go into its prompt), which is why `from_pddl` hands the domain and problem to every translator's `translate(predicates, domain, problem)`.\n",
"\n",
"The next cell mocks the OpenAI translation call, so you can see the cache workflow clearly without needing credentials."
]
},
{
"cell_type": "code",
"execution_count": 25,
"id": "e1b2e7b8",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:17:27.773226Z",
"iopub.status.busy": "2026-10-07T07:17:27.772595Z",
"iopub.status.idle": "2026-10-07T07:17:27.954369Z",
"shell.execute_reply": "2026-10-07T07:17:27.952673Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"First translation pass:\n",
"{'clear(blue)': 'Does the image show clear(blue)?',\n",
" 'on(blue,orange)': 'Does the image show on(blue,orange)?'}\n",
"\n",
"Predicates sent to the (mocked) LLM, in order:\n",
"['on(blue,orange)',\n",
" 'clear(blue)',\n",
" 'on(blue,blue)',\n",
" 'on(orange,blue)',\n",
" 'on(orange,orange)',\n",
" 'clear(orange)']\n",
"\n",
"Cached queries loaded from disk:\n",
"{'clear(blue)': 'Does the image show clear(blue)?',\n",
" 'clear(orange)': 'Does the image show clear(orange)?',\n",
" 'on(blue,blue)': 'Does the image show on(blue,blue)?',\n",
" 'on(blue,orange)': 'Does the image show on(blue,orange)?',\n",
" 'on(orange,blue)': 'Does the image show on(orange,blue)?',\n",
" 'on(orange,orange)': 'Does the image show on(orange,orange)?'}\n",
"\n",
"After save_cache() overrode one entry by hand:\n",
"{'clear(blue)': 'Does the image show clear(blue)?',\n",
" 'clear(orange)': 'Is the orange block free of anything on top?',\n",
" 'on(blue,blue)': 'Does the image show on(blue,blue)?',\n",
" 'on(blue,orange)': 'Does the image show on(blue,orange)?',\n",
" 'on(orange,blue)': 'Does the image show on(orange,blue)?',\n",
" 'on(orange,orange)': 'Does the image show on(orange,orange)?'}\n"
]
}
],
"source": [
"translated_predicates = [\"on(blue,orange)\", \"clear(blue)\"]\n",
"\n",
"with tempfile.TemporaryDirectory() as cache_dir:\n",
" translated_calls = []\n",
"\n",
" def fake_openai_translate(model_id, predicate, system_prompt, **kwargs):\n",
" translated_calls.append(predicate)\n",
" return f\"Does the image show {predicate}?\"\n",
"\n",
" with patch(\"s3e.translation.llm._openai_translate\", side_effect=fake_openai_translate), patch(\"s3e.translation.llm.tqdm\", new=lambda items, **kwargs: items):\n",
" translator = LLMTranslator(\"OpenAI/gpt-4o-mini\", cache_dir=cache_dir)\n",
" first_result = translator.translate(translated_predicates, COLOR_BLOCKSWORLD_DOMAIN, COLOR_BLOCKSWORLD_PROBLEM)\n",
" second_result = translator.translate(translated_predicates, COLOR_BLOCKSWORLD_DOMAIN, COLOR_BLOCKSWORLD_PROBLEM)\n",
"\n",
" # from_pddl passes the domain and problem through to the translator,\n",
" # so the remaining four predicates get translated (and cached) here.\n",
" llm_estimator = SemanticStateEstimator.from_pddl(\n",
" COLOR_BLOCKSWORLD_DOMAIN,\n",
" COLOR_BLOCKSWORLD_PROBLEM,\n",
" vlm=TutorialSceneVLM(),\n",
" translator=translator,\n",
" )\n",
"\n",
" cache_key = make_cache_key(\"OpenAI/gpt-4o-mini\", up_problem.name)\n",
" cached_queries = load_cache(cache_dir, cache_key)\n",
" save_cache(cache_dir, cache_key, {\"clear(orange)\": \"Is the orange block free of anything on top?\"})\n",
" merged_cache = load_cache(cache_dir, cache_key)\n",
"\n",
"assert first_result == second_result\n",
"assert translated_calls[:2] == translated_predicates\n",
"assert len(translated_calls) == 6 # two direct calls plus the estimator's four remaining predicates\n",
"assert len(llm_estimator.queries) == 6\n",
"\n",
"print(\"First translation pass:\")\n",
"pprint(first_result)\n",
"print()\n",
"print(\"Predicates sent to the (mocked) LLM, in order:\")\n",
"pprint(translated_calls)\n",
"print()\n",
"print(\"Cached queries loaded from disk:\")\n",
"pprint(cached_queries)\n",
"print()\n",
"print(\"After save_cache() overrode one entry by hand:\")\n",
"pprint(merged_cache)"
]
},
{
"cell_type": "markdown",
"id": "663b9556",
"metadata": {},
"source": [
"That is the sweet spot for the cache helpers in this repo: not as generic storage, but as a cheap memory for translator outputs you want to reuse or hand-edit.\n",
"\n",
"One gotcha is worth remembering. The cache key depends on the model ID, the PDDL problem name, and the inference kwargs. So if you quietly change the domain or problem contents but keep the same problem name, the cache cannot read your mind. In that case, clear or refresh it manually."
]
},
{
"cell_type": "markdown",
"id": "b9b594f4",
"metadata": {},
"source": [
"## 14. Handing the estimated state back into planning code\n",
"\n",
"Now for the satisfying part: turning model output back into planner-native objects. Once you have a `dict[str, bool]`, `estimator.to_up_state(state)` steps back into Unified Planning land, and the lower-level helpers in `s3e.pddl` are there when you need finer control.\n",
"\n",
"Every state is a complete `dict[str, bool]`, whatever `confidence` produced it, so it converts directly. Just remember what a strict threshold means downstream: Unified Planning reads every `False` as \"does not hold\", including the predicates the model simply was not sure enough about."
]
},
{
"cell_type": "code",
"execution_count": 26,
"id": "f5c3b6a0",
"metadata": {
"execution": {
"iopub.execute_input": "2026-10-07T07:17:27.957821Z",
"iopub.status.busy": "2026-10-07T07:17:27.957593Z",
"iopub.status.idle": "2026-10-07T07:17:27.967800Z",
"shell.execute_reply": "2026-10-07T07:17:27.966105Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Grounded predicate converted to a Unified Planning FNode:\n",
"on(blue, orange)\n",
"\n",
"Boolean constant as a Unified Planning expression:\n",
"true\n",
"\n",
"UP-compatible state mapping sample:\n",
"[(on(blue, blue), false), (on(blue, orange), true)]\n",
"\n",
"Unified Planning state:\n",
"{on(blue, orange): true, clear(blue): true}\n",
"\n",
"Unified Planning state at confidence=0.9 (no predicate reached the bar, so no fluent holds):\n",
"{}\n"
]
}
],
"source": [
"roundtrip_domain, roundtrip_problem = get_pddl_strings(up_problem)\n",
"predicate_fnode = ground_predicate_str_to_fnode(up_problem, \"on(blue,orange)\")\n",
"true_fnode = bool_constant_to_fnode(up_problem, True)\n",
"up_compatible_state = convert_state_dict_to_up_compatible(up_problem, state)\n",
"up_state = estimator.to_up_state(state)\n",
"strict_up_state = estimator.to_up_state(strict_state) # stricter states convert just the same\n",
"\n",
"assert \"(clear ?x - block)\" in roundtrip_domain\n",
"assert \"(on blue orange)\" in roundtrip_problem\n",
"assert predicate_fnode.fluent().name == \"on\"\n",
"\n",
"print(\"Grounded predicate converted to a Unified Planning FNode:\")\n",
"print(predicate_fnode)\n",
"print()\n",
"print(\"Boolean constant as a Unified Planning expression:\")\n",
"print(true_fnode)\n",
"print()\n",
"print(\"UP-compatible state mapping sample:\")\n",
"pprint(list(up_compatible_state.items())[:2])\n",
"print()\n",
"print(\"Unified Planning state:\")\n",
"print(up_state)\n",
"print()\n",
"print(\"Unified Planning state at confidence=0.9 (no predicate reached the bar, so no fluent holds):\")\n",
"print(strict_up_state)"
]
},
{
"cell_type": "markdown",
"id": "2256cdfa",
"metadata": {},
"source": [
"## 15. Other backend options\n",
"\n",
"We have only executed two backend paths in this notebook, but the public API has one more very convenient trick: you can often pass a model string instead of manually constructing the backend object yourself. `resolve_backend` is the factory behind it, and both `QueryEngine` and `SemanticStateEstimator` accept the string directly.\n",
"\n",
"If the string starts with `OpenAI/`, S3E builds an `OpenAIVLM` (requires the `[openai]` extra). Otherwise, it treats the string as a HuggingFace model ID and builds a `HuggingFaceVLM`. That makes small experiments pleasantly short.\n",
"\n",
"Here is what the OpenAI version looks like:\n",
"\n",
"```python\n",
"estimator = SemanticStateEstimator.from_pddl(\n",
" domain_pddl,\n",
" problem_pddl,\n",
" vlm=\"OpenAI/gpt-4o\",\n",
" vlm_kwargs={\"api_key\": \"...\"}, # client construction kwargs\n",
" inference_kwargs={\"temperature\": 0.2}, # per-query inference kwargs\n",
" translator=TemplateTranslator(TEMPLATE_MAP),\n",
")\n",
"state = estimator(images)\n",
"```\n",
"\n",
"The same split applies to HuggingFace string construction: keep load-time options in `vlm_kwargs` (for example `device_map` or `attn_implementation`) and runtime options in `inference_kwargs` (for example `max_new_tokens` when using `text_match`).\n",
"\n",
"vLLM is the one backend that never hides behind a string: it has engine-level knobs (`tensor_parallel_size`, `gpu_memory_utilization`, ...) that deserve to be visible, so you construct `VLLMBackend(...)` yourself and pass the instance, exactly as section 12 did. Remember that `vlm_kwargs` only apply to strings; pair them with an instance and you get a `ValueError` instead of silently ignored settings."
]
},
{
"cell_type": "markdown",
"id": "05316f04",
"metadata": {},
"source": [
"## Takeaways\n",
"\n",
"If you step away from this notebook remembering only a handful of ideas, make them these:\n",
"\n",
"- `SemanticStateEstimator` turns predicates into a state, whether they come from PDDL or an explicit list; `QueryEngine` underneath it answers any visual question, PDDL or not\n",
"- answer spaces decide what counts as an answer; translators decide how planner facts become model questions\n",
"- `PredictionSet` is the currency: probabilities, states, and raw outputs all derive from it, and it serializes without a backend\n",
"- calibration is offline: collect once, fit and refit as often as you like\n",
"- `keep_raw=True`, `unassigned_mass`, and `argmax_in_interest` are your best friends when a model behaves strangely\n",
"- probabilities are often more useful than booleans until the last possible moment; `confidence` only decides where the line falls, and every predicate lands on one side of it\n",
"- tiny HuggingFace models are great for checking the wiring, not for winning benchmarks\n",
"- thinking models answer without reasoning by default (`thinking=False` turns the template's switch off, or closes the block); pay for `thinking=True` only where held-out data shows the reasoning is worth its cost\n",
"- when predicate counts and GPUs grow, `VLLMBackend` keeps the same pipeline and swaps in an engine built for scale\n",
"- `to_up_state` lets you move back into Unified Planning once you have a symbolic state\n",
"\n",
"And if you decide to extend the package, the three interfaces to keep in your head are `QueryTranslator`, `AnswerSpace`, and `VLMBackend`. Those are the doors new behavior usually walks through."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "s3e",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.13"
}
},
"nbformat": 4,
"nbformat_minor": 5
}