Batch Jev questions per state and limit max_jev_calls by requests

This commit is contained in:
kantan-kanto
2026-10-03 14:02:41 +09:00
parent 5d4e5ac0af
commit 1f3bd2f7d4
2 changed files with 161 additions and 55 deletions
+39 -10
View File
@@ -16,8 +16,10 @@ from pathlib import Path
from typing import Any, Dict
from .jev_backend import (
UNANSWERED,
JevBackend,
LocalJev,
NeedAnswers,
TypeSafeClient,
TypeSafeJev,
list_local_models,
@@ -194,11 +196,13 @@ class _SafeScriptInterpreter:
def __init__(
self,
variables: Dict[str, Any],
now: datetime.datetime,
max_steps: int = _DEFAULT_MAX_STEPS,
max_call_depth: int = _DEFAULT_MAX_CALL_DEPTH,
jev: JevBackend | None = None,
):
self.frames = [variables]
self.now = now
self.jev = jev
self.max_steps = max_steps
self.max_call_depth = max_call_depth
@@ -392,6 +396,9 @@ class _SafeScriptInterpreter:
if name == "int":
return int(*args)
if name == "float":
# float() of a queued probability stays queued so storing it does not end the batch.
if len(args) == 1 and args[0] is UNANSWERED:
return UNANSWERED
return float(*args)
if name == "str":
return str(*args)
@@ -500,11 +507,11 @@ class _SafeScriptInterpreter:
if namespace == "datetime.datetime" and name == "now":
if args:
raise ValueError("datetime.datetime.now() expects no arguments")
return datetime.datetime.now()
return self.now
if namespace == "datetime.date" and name == "today":
if args:
raise ValueError("datetime.date.today() expects no arguments")
return datetime.date.today()
return self.now.date()
if namespace == "math" and name in _SAFE_MATH_FUNCTIONS:
return self._call_math(name, args)
if namespace == "jev":
@@ -516,11 +523,13 @@ class _SafeScriptInterpreter:
if len(args) not in (2, 3):
raise ValueError("jev.yes() expects 2 or 3 arguments")
threshold = args[2] if len(args) == 3 else 0.5
return self.jev.noul(args[0], args[1]) >= threshold
p = self.jev.noul(args[0], args[1])
return p if p is UNANSWERED else p >= threshold
if name == "noul":
if len(args) != 2:
raise ValueError("jev.noul() expects 2 arguments")
return _JevProbability(self.jev.noul(*args))
p = self.jev.noul(*args)
return p if p is UNANSWERED else _JevProbability(p)
if name == "choice":
if len(args) != 3:
raise ValueError("jev.choice() expects 3 arguments")
@@ -680,6 +689,8 @@ class _SafeScriptInterpreter:
return left > right
if isinstance(op, ast.GtE):
return left >= right
if isinstance(op, (ast.Is, ast.IsNot)) and (left is UNANSWERED or right is UNANSWERED):
raise NeedAnswers()
if isinstance(op, ast.Is):
return left is right
if isinstance(op, ast.IsNot):
@@ -868,7 +879,7 @@ class MultiOutputScript:
in_value_3: Any = None,
):
return _run_script(
code, None, in_text_1, in_text_2, in_text_3, in_value_1, in_value_2, in_value_3
code, None, datetime.datetime.now(), in_text_1, in_text_2, in_text_3, in_value_1, in_value_2, in_value_3
)
@@ -926,21 +937,38 @@ class MultiOutputScriptJev(MultiOutputScript):
inputs = (in_text_1, in_text_2, in_text_3, in_value_1, in_value_2, in_value_3)
if model == _TYPESAFE_API:
with _typesafe_client() as client:
return _run_jev_script(code, JevBackend(TypeSafeJev(client), max_jev_calls), inputs)
return _run_jev_script(code, JevBackend(TypeSafeJev(client), max_jev_calls, True), inputs)
model_path = list_local_models().get(model)
if model_path is None:
raise ValueError(f"GGUF model not found under models/LLM or models/text_encoders: {model}")
try:
return _run_jev_script(code, JevBackend(LocalJev(model_path), max_jev_calls), inputs)
return _run_jev_script(code, JevBackend(LocalJev(model_path), max_jev_calls, False), inputs)
finally:
unload_local_model()
def _run_jev_script(code: str, jev: JevBackend, inputs: tuple):
"""Reruns the script until it finishes, asking the questions queued before each stop in one batch.
Every rerun starts from the same random state and clock, so it asks the same questions as the run before.
A script error while questions are queued may come from a queued answer, so those are asked and the script reruns.
"""
random_state = random.getstate()
now = datetime.datetime.now()
try:
return _run_script(code, jev, *inputs)
while True:
random.setstate(random_state)
try:
return _run_script(code, jev, now, *inputs)
except Exception:
if not jev.pending:
raise
jev.flush()
finally:
print(f"[ComfyUI-ScriptFlow] Jev requests: {jev.calls}, responses: {jev.responses}")
print(
f"[ComfyUI-ScriptFlow] Jev requests: {jev.requests}, states: {len(jev.states)}, "
f"questions: {jev.questions}, responses: {jev.responses}"
)
def _typesafe_client() -> Any:
@@ -961,6 +989,7 @@ def _typesafe_client() -> Any:
def _run_script(
code: str,
jev: JevBackend | None,
now: datetime.datetime,
in_text_1: Any,
in_text_2: Any,
in_text_3: Any,
@@ -990,7 +1019,7 @@ def _run_script(
"ov3": None,
}
locals_dict = _SafeScriptInterpreter(locals_dict, jev=jev).run(code)
locals_dict = _SafeScriptInterpreter(locals_dict, now, jev=jev).run(code)
_validate_outputs(locals_dict)
+122 -45
View File
@@ -15,6 +15,7 @@ import gc
import json
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any
import comfy.model_management
@@ -38,7 +39,7 @@ except ImportError:
try:
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
# One INFO line pair per request floods the console; the run summary is printed instead.
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpx2").setLevel(logging.WARNING)
logging.getLogger("typesafe_sdk").setLevel(logging.WARNING)
except ImportError:
TypeSafeClient = None
@@ -54,79 +55,155 @@ _N_CTX = 8192
_loaded_model: tuple[str, Any, Any] | None = None
class JevBackend:
"""Caches answers and limits model calls for a single script run."""
class NeedAnswers(Exception):
"""Raised when a script uses an answer that is still queued."""
def __init__(self, engine: Any, max_calls: int):
class _Unanswered:
"""Stands in for a queued answer. Storing or passing it is fine; using its value raises NeedAnswers."""
def _need(self, *args: Any) -> Any:
raise NeedAnswers()
__bool__ = __eq__ = __ne__ = __lt__ = __le__ = __gt__ = __ge__ = __hash__ = _need
__str__ = __repr__ = __format__ = __float__ = __int__ = __index__ = _need
__len__ = __iter__ = __contains__ = __getitem__ = _need
UNANSWERED = _Unanswered()
def _json_default(value: Any) -> Any:
if value is UNANSWERED:
raise NeedAnswers()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
class JevBackend:
"""Queues questions during a script run and asks them in batches, one request per state.
The caller reruns the script after each flush(); answered questions then return their values from the cache.
max_calls limits the number of requests.
"""
def __init__(self, engine: Any, max_calls: int, parallel: bool):
self.engine = engine
self.max_calls = max_calls
self.calls = 0
self.parallel = parallel
self.requests = 0
self.responses = 0
self.cache: dict[str, Any] = {}
self.questions = 0
self.states: set[str] = set()
self.cache: dict[str, list[float]] = {}
self.pending: dict[str, tuple] = {}
def noul(self, state: Any, question: Any) -> float:
return self._ask(state, "noul", question, None)[0]
def noul(self, state: Any, question: Any) -> Any:
probabilities = self._ask(state, "noul", question, None)
return UNANSWERED if probabilities is None else probabilities[0]
def choice(self, state: Any, question: Any, options: list | dict) -> str:
def choice(self, state: Any, question: Any, options: list | dict) -> Any:
probabilities = self.probabilities(state, question, options)
return max(probabilities, key=probabilities.get)
return UNANSWERED if probabilities is UNANSWERED else max(probabilities, key=probabilities.get)
def probabilities(self, state: Any, question: Any, options: list | dict) -> dict[str, float]:
def probabilities(self, state: Any, question: Any, options: list | dict) -> Any:
if isinstance(options, list):
options = {str(option): None for option in options}
return dict(zip(options, self._ask(state, "choice", question, options)))
probabilities = self._ask(state, "choice", question, options)
return UNANSWERED if probabilities is None else dict(zip(options, probabilities))
def score(self, state: Any, question: Any, levels: list) -> float:
def score(self, state: Any, question: Any, levels: list) -> Any:
probabilities = self._ask(state, "score", question, list(levels))
return sum(level * p for level, p in enumerate(probabilities))
return UNANSWERED if probabilities is None else sum(level * p for level, p in enumerate(probabilities))
def _ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float]:
key = json.dumps([state, kind, question, criteria], sort_keys=True, ensure_ascii=False)
def _ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float] | None:
key = json.dumps([state, kind, question, criteria], sort_keys=True, ensure_ascii=False, default=_json_default)
if key in self.cache:
return self.cache[key]
if self.calls >= self.max_calls:
raise RuntimeError(f"Script exceeded max_jev_calls ({self.max_calls})")
self.calls += 1
probabilities = self.engine.ask(state, kind, question, criteria)
self.responses += 1
self.cache[key] = probabilities
return probabilities
self.pending[key] = (state, kind, question, criteria)
return None
def flush(self) -> None:
groups: dict[str, list[str]] = {}
for key, (state, *_) in self.pending.items():
groups.setdefault(json.dumps(state, sort_keys=True, ensure_ascii=False), []).append(key)
if self.requests + len(groups) > self.max_calls:
raise RuntimeError(
f"Script needs more than max_jev_calls ({self.max_calls}) requests: "
f"{self.requests} sent, {len(groups)} more needed"
)
self.requests += len(groups)
self.questions += len(self.pending)
self.states.update(groups)
batches = list(groups.values())
self.pending, pending = {}, self.pending
if self.parallel and len(batches) > 1:
with ThreadPoolExecutor(max_workers=len(batches)) as pool:
futures = [pool.submit(self._send, pending, keys) for keys in batches]
errors = [future.exception() for future in futures]
self.responses += errors.count(None)
for error in errors:
if error is not None:
raise error
return
for keys in batches:
self._send(pending, keys)
self.responses += 1
def _send(self, pending: dict[str, tuple], keys: list[str]) -> None:
state = pending[keys[0]][0]
answers = self.engine.ask(state, [pending[key][1:] for key in keys])
for key, probabilities in zip(keys, answers):
self.cache[key] = probabilities
class LocalJev:
"""Answers with a local GGUF model. Noul is asked as Yes/No options."""
"""Answers with a local GGUF model, one forward pass per question. Noul is asked as Yes/No options."""
def __init__(self, model_path: str):
self.model_path = model_path
def ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float]:
if kind == "noul":
options = ["Yes", "No"]
elif kind == "choice":
options = [label if description is None else f"{label}: {description}" for label, description in criteria.items()]
else:
options = criteria
return _option_probabilities(self.model_path, state, question, options)
def ask(self, state: Any, questions: list[tuple]) -> list[list[float]]:
answers = []
for kind, question, criteria in questions:
if kind == "noul":
options = ["Yes", "No"]
elif kind == "choice":
options = [label if description is None else f"{label}: {description}" for label, description in criteria.items()]
else:
options = criteria
answers.append(_option_probabilities(self.model_path, state, question, options))
return answers
class TypeSafeJev:
"""Answers with TypeSafe's Jev API. Returns probabilities in the order of the options."""
"""Answers with TypeSafe's Jev API, all questions about one state in one request.
Returns probabilities in the order of the options.
"""
def __init__(self, client: Any):
self.client = client
def ask(self, state: Any, kind: str, question: Any, criteria: Any) -> list[float]:
if kind == "noul":
p = self._answer(state, Noul(instructions=question)).noul
return [p, 1.0 - p]
if kind == "choice":
probabilities = self._answer(state, Choice(instructions=question, criteria=criteria)).probabilities
return [probabilities[label] for label in criteria]
probabilities = self._answer(state, Score(instructions=question, criteria=criteria)).probabilities
return [probabilities[level] for level in range(len(criteria))]
def _answer(self, state: Any, question: Any) -> Any:
return self.client.system_one(state=state, questions={"q": question}).answers["q"]
def ask(self, state: Any, questions: list[tuple]) -> list[list[float]]:
typed = {}
for i, (kind, question, criteria) in enumerate(questions):
if kind == "noul":
typed[f"q{i}"] = Noul(instructions=question)
elif kind == "choice":
typed[f"q{i}"] = Choice(instructions=question, criteria=criteria)
else:
typed[f"q{i}"] = Score(instructions=question, criteria=criteria)
answers = self.client.system_one(state=state, questions=typed).answers
results = []
for i, (kind, _, criteria) in enumerate(questions):
answer = answers[f"q{i}"]
if kind == "noul":
results.append([answer.noul, 1.0 - answer.noul])
elif kind == "choice":
results.append([answer.probabilities[label] for label in criteria])
else:
results.append([answer.probabilities[level] for level in range(len(criteria))])
return results
def _option_probabilities(model_path: str, state: Any, question: Any, options: list) -> list[float]: