qev 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. qev/__init__.py +28 -0
  2. qev/__main__.py +5 -0
  3. qev/cli.py +130 -0
  4. qev/community.py +10 -0
  5. qev/download.py +118 -0
  6. qev/playground/__init__.py +1 -0
  7. qev/playground/app.py +243 -0
  8. qev/playground/engine.py +121 -0
  9. qev/playground/presets.json +270 -0
  10. qev/playground/samples/LICENSE.txt +21 -0
  11. qev/playground/samples/README.md +21 -0
  12. qev/playground/samples/provenance.json +75 -0
  13. qev/playground/samples/sample-01.jpg +0 -0
  14. qev/playground/samples/sample-02.jpg +0 -0
  15. qev/playground/samples/sample-03.jpg +0 -0
  16. qev/playground/samples/sample-04.jpg +0 -0
  17. qev/playground/samples/sample-05.jpg +0 -0
  18. qev/playground/samples/sample-06.jpg +0 -0
  19. qev/runtime.py +69 -0
  20. qev-0.2.0.dist-info/METADATA +271 -0
  21. qev-0.2.0.dist-info/RECORD +63 -0
  22. qev-0.2.0.dist-info/WHEEL +4 -0
  23. qev-0.2.0.dist-info/entry_points.txt +3 -0
  24. qev-0.2.0.dist-info/licenses/LICENSE +201 -0
  25. qev-0.2.0.dist-info/licenses/NOTICE +71 -0
  26. veyra/__init__.py +3 -0
  27. veyra/augment_data.py +133 -0
  28. veyra/average_adapters.py +54 -0
  29. veyra/backbone.py +167 -0
  30. veyra/benchmark.py +136 -0
  31. veyra/binding_head.py +32 -0
  32. veyra/build_data.py +383 -0
  33. veyra/calibrate.py +130 -0
  34. veyra/candidates.py +60 -0
  35. veyra/checkpoint.py +82 -0
  36. veyra/cli.py +171 -0
  37. veyra/constants.py +5 -0
  38. veyra/data.py +110 -0
  39. veyra/decision_metrics.py +126 -0
  40. veyra/evaluate.py +108 -0
  41. veyra/evidence_data.py +228 -0
  42. veyra/features.py +195 -0
  43. veyra/head.py +99 -0
  44. veyra/interventions.py +169 -0
  45. veyra/model.py +84 -0
  46. veyra/option_model.py +432 -0
  47. veyra/packing.py +100 -0
  48. veyra/policy_data.py +255 -0
  49. veyra/policy_refresh.py +181 -0
  50. veyra/probability.py +72 -0
  51. veyra/proper_learning.py +100 -0
  52. veyra/reasoning_workspace.py +32 -0
  53. veyra/release_gate.py +146 -0
  54. veyra/replay.py +90 -0
  55. veyra/schema.py +86 -0
  56. veyra/server.py +57 -0
  57. veyra/synthetic_audit.py +66 -0
  58. veyra/training.py +191 -0
  59. veyra/transfer_learning.py +84 -0
  60. veyra/workflow_consistency.py +107 -0
  61. veyra/workflow_facts.py +54 -0
  62. veyra/workflow_learning.py +108 -0
  63. veyra/workflow_release_gate.py +85 -0
qev/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """QEV public interface to the evaluated multimodal decision runtime."""
2
+
3
+ __version__ = "0.2.0"
4
+ __all__ = ["DecisionRequest", "QEV", "load", "__version__"]
5
+
6
+
7
+ def load(checkpoint=None, *, device="auto", cache_dir=None, offline=False):
8
+ """Load QEV, downloading its pinned weights on the first use.
9
+
10
+ Later calls reuse the local model cache. ``offline=True`` requires all weights
11
+ to be available already. ``QEV.load`` remains the original low-level loader.
12
+ """
13
+ from qev.runtime import load_model
14
+
15
+ return load_model(checkpoint, device=device, cache_dir=cache_dir, offline=offline)
16
+
17
+
18
+ def __getattr__(name):
19
+ # CLI help and packaging metadata do not need to initialize PyTorch.
20
+ if name == "QEV":
21
+ from veyra.option_model import OptionModel
22
+
23
+ return OptionModel
24
+ if name == "DecisionRequest":
25
+ from veyra.schema import DecisionRequest
26
+
27
+ return DecisionRequest
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
qev/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Support python -m qev as well as the installed qev command."""
2
+
3
+ from qev.cli import main
4
+
5
+ main()
qev/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ """SDK, local playground and first-use model downloads in one installed command."""
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from qev import __version__
10
+
11
+
12
+ def main(argv=None):
13
+ parser = argparse.ArgumentParser(prog="qev")
14
+ parser.add_argument("--version", action="version", version=f"qev {__version__}")
15
+ commands = parser.add_subparsers(dest="command", required=True)
16
+ download = commands.add_parser("download", help="Download the pinned model for later use")
17
+ download.add_argument("--cache-dir")
18
+ download.add_argument("--output", "--checkpoint", type=Path)
19
+ download.add_argument("--checkpoint-only", action="store_true")
20
+ download.add_argument("--base-only", action="store_true")
21
+ download.add_argument(
22
+ "--offline", action="store_true", default=os.environ.get("QEV_OFFLINE") == "1"
23
+ )
24
+ for name in ("predict", "serve", "playground"):
25
+ command = commands.add_parser(name)
26
+ command.add_argument(
27
+ "--checkpoint", type=Path, help="Checkpoint folder; default: QEV cache"
28
+ )
29
+ command.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
30
+ command.add_argument("--cache-dir", help="Hugging Face cache; also accepts QEV_CACHE_DIR")
31
+ command.add_argument(
32
+ "--offline", action="store_true", default=os.environ.get("QEV_OFFLINE") == "1"
33
+ )
34
+ if name == "predict":
35
+ command.add_argument("--request", type=Path, required=True)
36
+ else:
37
+ command.add_argument("--host", default="127.0.0.1")
38
+ command.add_argument("--port", type=int, default=7860 if name == "playground" else 8000)
39
+ if name == "serve":
40
+ command.add_argument("--image-root", type=Path, default=Path.cwd())
41
+ else:
42
+ command.add_argument(
43
+ "--open", action="store_true", help="Open the playground in a browser"
44
+ )
45
+ for name in ("star", "support"):
46
+ command = commands.add_parser(name, help=f"Show the QEV {name} page")
47
+ command.add_argument("--open", action="store_true", help="Open the page in your browser")
48
+ args = parser.parse_args(argv)
49
+ if args.command == "download" and args.base_only and args.checkpoint_only:
50
+ parser.error("--base-only and --checkpoint-only cannot be combined")
51
+ if hasattr(args, "port") and not 1 <= args.port <= 65535:
52
+ parser.error("--port must be between 1 and 65535")
53
+ try:
54
+ run(args)
55
+ except (ValueError, RuntimeError, OSError) as error:
56
+ parser.exit(1, f"qev: {error}\n")
57
+
58
+
59
+ def run(args):
60
+ if args.command in {"star", "support"}:
61
+ from qev.community import REPOSITORY_URL, SUPPORT_URL
62
+
63
+ url = REPOSITORY_URL if args.command == "star" else SUPPORT_URL
64
+ print(url)
65
+ if args.open:
66
+ import webbrowser
67
+
68
+ webbrowser.open(url)
69
+ return
70
+ from qev.runtime import cache_home, load_model, prepare_checkpoint, resolve_cache
71
+
72
+ def progress(message):
73
+ print(message, file=sys.stderr, flush=True)
74
+
75
+ if args.command == "download":
76
+ from qev.download import CHECKPOINT_REVISION, download_backbone, download_checkpoint
77
+
78
+ folder = args.output or cache_home() / "checkpoints" / CHECKPOINT_REVISION
79
+ cache = resolve_cache(args.cache_dir)
80
+ if args.checkpoint_only:
81
+ result = {
82
+ "checkpoint": str(download_checkpoint(folder, cache, local_files_only=args.offline))
83
+ }
84
+ elif args.base_only:
85
+ result = {"backbone_cache": download_backbone(cache, local_files_only=args.offline)}
86
+ else:
87
+ folder, cache = prepare_checkpoint(
88
+ folder, cache_dir=cache, offline=args.offline, progress=progress
89
+ )
90
+ result = {"checkpoint": str(folder), "backbone_cache": cache}
91
+ print(json.dumps(result, indent=2))
92
+ return
93
+ if args.command == "playground":
94
+ from qev.playground.app import launch
95
+
96
+ launch(
97
+ checkpoint=args.checkpoint,
98
+ device=args.device,
99
+ cache_dir=args.cache_dir,
100
+ offline=args.offline,
101
+ host=args.host,
102
+ port=args.port,
103
+ inbrowser=args.open,
104
+ )
105
+ return
106
+ import torch
107
+
108
+ from qev import DecisionRequest
109
+
110
+ torch.set_num_threads(4)
111
+ request = None
112
+ if args.command == "predict":
113
+ request = DecisionRequest.from_json(args.request.read_text(encoding="utf-8"))
114
+ elif not args.image_root.is_dir():
115
+ raise ValueError("--image-root must be an existing directory")
116
+ model = load_model(
117
+ args.checkpoint,
118
+ device=args.device,
119
+ cache_dir=args.cache_dir,
120
+ offline=args.offline,
121
+ progress=progress,
122
+ )
123
+ if request is not None:
124
+ print(json.dumps(model.predict(request, args.request.parent.resolve()), indent=2))
125
+ else:
126
+ import uvicorn
127
+
128
+ from veyra.server import create_app
129
+
130
+ uvicorn.run(create_app(model, args.image_root), host=args.host, port=args.port)
qev/community.py ADDED
@@ -0,0 +1,10 @@
1
+ """Public community links; opening a page always requires a user action."""
2
+
3
+ REPOSITORY_URL = "https://github.com/ken-jo/qev"
4
+ SUPPORT_URL = "https://github.com/sponsors/ken-jo"
5
+
6
+
7
+ def community_markdown():
8
+ return (
9
+ "[Star QEV on GitHub](" + REPOSITORY_URL + ") · [GitHub Sponsors](" + SUPPORT_URL + ")"
10
+ )
qev/download.py ADDED
@@ -0,0 +1,118 @@
1
+ """Download the immutable QEV decision checkpoint and its upstream backbone."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ CHECKPOINT_ID = "ken-jo/qev"
10
+ # The repository rename preserves this original checkpoint revision.
11
+ CHECKPOINT_REVISION = "0d2d13ffb3c392071ea00b6bdb903c4b84dff48d"
12
+ CHECKPOINT_HASHES = {
13
+ "head.safetensors": "84b57aeeb987f73416ac0f796150957d1c5beb1ed373733053e46438f77a8fee",
14
+ "manifest.json": "d83f9910196c6e801658ca3816c3d2bd4a849ba3da6ff059a0edf7c6028e898d",
15
+ }
16
+
17
+
18
+ def download_checkpoint(output: Path, cache_dir: str, *, local_files_only=False) -> Path:
19
+ """Copy verified checkpoint files into an explicit local directory."""
20
+ from filelock import FileLock
21
+ from huggingface_hub import hf_hub_download
22
+
23
+ output = output.resolve()
24
+ existing = []
25
+ for name, expected in CHECKPOINT_HASHES.items():
26
+ target = output / name
27
+ if target.exists():
28
+ if hashlib.sha256(target.read_bytes()).hexdigest() != expected:
29
+ raise ValueError(f"Refusing to replace a different checkpoint file: {target}")
30
+ existing.append(name)
31
+ if len(existing) == len(CHECKPOINT_HASHES):
32
+ return output
33
+ output.mkdir(parents=True, exist_ok=True)
34
+ locks = Path(cache_dir) / ".qev-locks"
35
+ locks.mkdir(parents=True, exist_ok=True)
36
+ lock_name = hashlib.sha256(os.path.normcase(str(output)).encode()).hexdigest() + ".lock"
37
+ with FileLock(str(locks / lock_name)):
38
+ for name, expected in CHECKPOINT_HASHES.items():
39
+ destination = output / name
40
+ if destination.exists():
41
+ if hashlib.sha256(destination.read_bytes()).hexdigest() != expected:
42
+ raise ValueError(
43
+ f"Refusing to replace a different checkpoint file: {destination}"
44
+ )
45
+ continue
46
+ source = Path(
47
+ hf_hub_download(
48
+ CHECKPOINT_ID,
49
+ name,
50
+ revision=CHECKPOINT_REVISION,
51
+ cache_dir=cache_dir,
52
+ local_files_only=local_files_only,
53
+ token=False,
54
+ )
55
+ )
56
+ raw = source.read_bytes()
57
+ if hashlib.sha256(raw).hexdigest() != expected:
58
+ raise ValueError(f"Checkpoint integrity check failed: {name}")
59
+ temporary = None
60
+ try:
61
+ with tempfile.NamedTemporaryFile(
62
+ dir=output, suffix=".partial", delete=False
63
+ ) as stream:
64
+ temporary = Path(stream.name)
65
+ stream.write(raw)
66
+ os.replace(temporary, destination)
67
+ finally:
68
+ if temporary is not None:
69
+ temporary.unlink(missing_ok=True)
70
+ return output
71
+
72
+
73
+ def download_backbone(cache_dir: str, *, local_files_only=False) -> str:
74
+ """Download the exact upstream weights and preprocessing files."""
75
+ from huggingface_hub import snapshot_download
76
+
77
+ from veyra.constants import MODEL_ID, MODEL_REVISION
78
+
79
+ folder = Path(
80
+ snapshot_download(
81
+ MODEL_ID,
82
+ revision=MODEL_REVISION,
83
+ cache_dir=cache_dir,
84
+ local_files_only=local_files_only,
85
+ allow_patterns=["*.json", "*.safetensors", "*.jinja", "*.txt"],
86
+ token=False,
87
+ )
88
+ )
89
+ required = {
90
+ "config.json",
91
+ "preprocessor_config.json",
92
+ "tokenizer.json",
93
+ "tokenizer_config.json",
94
+ "chat_template.jinja",
95
+ "merges.txt",
96
+ "vocab.json",
97
+ "video_preprocessor_config.json",
98
+ "model.safetensors.index.json",
99
+ }
100
+ index = folder / "model.safetensors.index.json"
101
+ if index.is_file():
102
+ required.update(json.loads(index.read_text("utf-8"))["weight_map"].values())
103
+ missing = []
104
+ for name in required:
105
+ # Hub snapshots may symlink weights into the adjacent blob cache.
106
+ path = folder / name
107
+ if (
108
+ Path(name).is_absolute()
109
+ or ".." in Path(name).parts
110
+ or not path.is_file()
111
+ or path.stat().st_size == 0
112
+ ):
113
+ missing.append(name)
114
+ if missing:
115
+ from huggingface_hub.errors import LocalEntryNotFoundError
116
+
117
+ raise LocalEntryNotFoundError("Incomplete Qwen snapshot: " + ", ".join(sorted(missing)))
118
+ return str(folder)
@@ -0,0 +1 @@
1
+ """English playground and licensed sample photographs included with the QEV SDK."""
qev/playground/app.py ADDED
@@ -0,0 +1,243 @@
1
+ """English local playground shipped inside the QEV Python distribution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
12
+ import gradio as gr # noqa: E402
13
+
14
+ from qev.community import community_markdown # noqa: E402
15
+ from qev.playground.engine import DemoEngine # noqa: E402
16
+
17
+ ROOT = Path(__file__).resolve().parent
18
+ PRESETS = json.loads((ROOT / "presets.json").read_text("utf-8"))
19
+ LABELS = {
20
+ "support": "Text / Route a support request",
21
+ "policy": "Text / Apply an order policy",
22
+ "uncertain": "Text / Check insufficient evidence",
23
+ "photo": "Image / Choose the material",
24
+ "photo_score": "Image / Score visibility",
25
+ "photo_truth": "Image / Judge a proposition",
26
+ "photo_policy": "Image + text / Apply a sorting policy",
27
+ }
28
+ SAMPLES = [
29
+ ("sample-01.jpg", "Glass bottle"),
30
+ ("sample-02.jpg", "Newspaper"),
31
+ ("sample-03.jpg", "Cardboard"),
32
+ ("sample-04.jpg", "Plastic bottle"),
33
+ ("sample-05.jpg", "Metal can"),
34
+ ("sample-06.jpg", "Packaging pouch"),
35
+ ]
36
+ CSS = """
37
+ .gradio-container { max-width: 1200px !important; }
38
+ #intro { padding: 12px 0 20px; }
39
+ #intro h1 { letter-spacing: -.04em; font-size: 34px; line-height: 1.12; }
40
+ #intro p { max-width: 760px; }
41
+ #run-button { min-height: 48px; }
42
+ footer { display: none !important; }
43
+ """
44
+
45
+
46
+ def preset_values(key):
47
+ preset = PRESETS[key]
48
+ question = preset["questions"][0]
49
+ options = question["options"]
50
+ criteria = "\n".join(
51
+ item["text"] if question["type"] == "score" else f"{item['key']} | {item['text']}"
52
+ for item in options
53
+ )
54
+ image = str(ROOT / "samples" / (preset["sampleId"] + ".jpg")) if "sampleId" in preset else None
55
+ return preset["text"], image, question["instructions"], question["type"], criteria
56
+
57
+
58
+ def default_criteria(kind):
59
+ if kind == "noul":
60
+ return "false | The proposition is false.\ntrue | The proposition is true."
61
+ if kind == "score":
62
+ return (
63
+ "Low: the criterion is not met.\nMedium: the criterion is partly met.\n"
64
+ "High: the criterion is fully met."
65
+ )
66
+ return "yes | The evidence meets the criterion.\nno | The evidence does not meet the criterion."
67
+
68
+
69
+ def runtime_notice(device, shared_gpu=False):
70
+ if shared_gpu:
71
+ return (
72
+ "**Shared GPU demo.** GPU acceleration is enabled. Response time depends on "
73
+ "GPU availability, input size and the queue. Daily usage limits apply."
74
+ )
75
+ if device == "cuda":
76
+ return (
77
+ "**GPU demo.** GPU acceleration is enabled. "
78
+ "Response time depends on input size and the queue."
79
+ )
80
+ return (
81
+ "**CPU demo.** Responses may take several seconds. GPU hosting can make model "
82
+ "processing faster; input size and queue time also affect how long you wait."
83
+ )
84
+
85
+
86
+ def build_demo(engine):
87
+ def predict(*args):
88
+ try:
89
+ return engine.predict(*args)
90
+ except (ValueError, TypeError) as error:
91
+ raise gr.Error(str(error)) from error
92
+ except Exception as error:
93
+ logging.exception("Demo inference failed")
94
+ raise gr.Error(
95
+ "The model could not process this request. Please try a shorter example."
96
+ ) from error
97
+
98
+ initial = preset_values("support")
99
+ with gr.Blocks(title="QEV", delete_cache=(300, 600)) as demo:
100
+ gr.Markdown(
101
+ "# QEV\n"
102
+ "Provide the evidence. Define your candidates. Inspect the probabilities.\n\n"
103
+ "Dynamic text and image decisions with **Qwen3.5-2B**. "
104
+ "[Model](https://huggingface.co/ken-jo/qev) · "
105
+ "[Data](https://huggingface.co/datasets/ken-jo/qev-data) · "
106
+ "[Source](https://github.com/ken-jo/qev)",
107
+ elem_id="intro",
108
+ )
109
+ gr.Markdown(runtime_notice(engine.device))
110
+ with gr.Row(equal_height=False):
111
+ with gr.Column(scale=6):
112
+ example = gr.Dropdown(
113
+ choices=[(value, key) for key, value in LABELS.items()],
114
+ value="support",
115
+ label="Start with an example",
116
+ )
117
+ evidence = gr.Textbox(
118
+ value=initial[0], label="Evidence and context", lines=4, max_lines=8
119
+ )
120
+ image = gr.Image(
121
+ type="pil",
122
+ image_mode="RGB",
123
+ sources=["upload", "clipboard"],
124
+ label="Image (optional)",
125
+ height=250,
126
+ )
127
+ gr.Examples(
128
+ examples=[[str(ROOT / "samples" / name)] for name, _ in SAMPLES],
129
+ inputs=[image],
130
+ label="Sample photographs",
131
+ cache_examples=False,
132
+ )
133
+ gr.Markdown(
134
+ "Photos: TrashNet, Gary Thung & Mindy Yang (MIT). These examples overlap "
135
+ "development data; they are not a benchmark."
136
+ )
137
+ kind = gr.Radio(
138
+ ["choice", "score", "noul"], value=initial[3], label="Decision type"
139
+ )
140
+ question = gr.Textbox(value=initial[2], label="Question and decision rule", lines=2)
141
+ criteria = gr.Textbox(
142
+ value=initial[4], label="Candidate descriptions / score levels", lines=5
143
+ )
144
+ gr.Markdown(
145
+ "**choice:** `label | description`, one per line. "
146
+ "**score:** one description per line, from level 0 upward. "
147
+ "**noul:** use `false | ...` and `true | ...`."
148
+ )
149
+ resolution = gr.Dropdown(
150
+ ["Original", "64", "128", "192", "256", "384", "512", "768", "1024"],
151
+ value="Original",
152
+ label="Image long edge (pixels)",
153
+ info=(
154
+ "Resize without cropping or upscaling. "
155
+ "The model then applies its trained pixel budget."
156
+ ),
157
+ )
158
+ run = gr.Button("Run decision", variant="primary", elem_id="run-button")
159
+ with gr.Column(scale=5):
160
+ summary = gr.Textbox(label="Decision", lines=6, interactive=False)
161
+ distribution = gr.Label(label="Candidate probabilities", num_top_classes=16)
162
+ gr.Markdown(
163
+ "Probabilities are model estimates, not verified accuracy. "
164
+ "An abstained prediction needs review. "
165
+ "Score is an expected level, not a confidence percentage."
166
+ )
167
+ with gr.Accordion("Request and response JSON", open=False):
168
+ request = gr.Code(label="Request", language="json", interactive=False)
169
+ response = gr.JSON(label="Response")
170
+ with gr.Accordion("Scope and limitations", open=False):
171
+ gr.Markdown(
172
+ "One image, one question and 2–16 candidates per demo request. "
173
+ "The full API supports up to four questions. OCR and spatial reasoning "
174
+ "are weak; 2048 experiments produced no wins. English is the main "
175
+ "evaluated language. Inputs are processed on the machine running QEV. "
176
+ "Per-request image files are removed after inference; cached uploads "
177
+ "expire after approximately 10 minutes."
178
+ )
179
+
180
+ def clear_results():
181
+ return "Inputs changed. Run a decision to see the new result.", None, None, None
182
+
183
+ result_outputs = [summary, distribution, request, response]
184
+ example.change(
185
+ preset_values,
186
+ [example],
187
+ [evidence, image, question, kind, criteria],
188
+ queue=False,
189
+ api_name=False,
190
+ ).then(clear_results, [], result_outputs, queue=False, api_name=False)
191
+ kind.input(default_criteria, [kind], [criteria], queue=False, api_name=False)
192
+ for control in (evidence, image, question, kind, criteria, resolution):
193
+ control.input(clear_results, [], result_outputs, queue=False, api_name=False)
194
+ run.click(
195
+ predict,
196
+ [evidence, image, question, kind, criteria, resolution],
197
+ [summary, distribution, request, response],
198
+ api_name="predict",
199
+ concurrency_limit=1,
200
+ concurrency_id="model",
201
+ )
202
+ gr.Markdown(community_markdown())
203
+ return demo
204
+
205
+
206
+ def launch(
207
+ *,
208
+ checkpoint=None,
209
+ device="auto",
210
+ cache_dir=None,
211
+ offline=False,
212
+ host="127.0.0.1",
213
+ port=7860,
214
+ inbrowser=False,
215
+ ):
216
+ logging.basicConfig(level=logging.INFO)
217
+
218
+ def progress(message):
219
+ print(message, file=sys.stderr, flush=True)
220
+
221
+ print("QEV local playground. Optional: star or support QEV at https://github.com/ken-jo/qev")
222
+ engine = DemoEngine(
223
+ device=device,
224
+ checkpoint=checkpoint,
225
+ cache_dir=cache_dir,
226
+ offline=offline,
227
+ ).load(progress=progress)
228
+ demo = build_demo(engine)
229
+ demo.queue(max_size=8, default_concurrency_limit=1)
230
+ demo.launch(
231
+ server_name=host,
232
+ server_port=port,
233
+ inbrowser=inbrowser,
234
+ share=False,
235
+ show_error=False,
236
+ max_file_size="10mb",
237
+ css=CSS,
238
+ theme=gr.themes.Base(primary_hue="blue"),
239
+ )
240
+
241
+
242
+ if __name__ == "__main__":
243
+ launch()
@@ -0,0 +1,121 @@
1
+ """Public demo adapter around the released, unchanged decision runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ from PIL import Image, ImageOps
13
+
14
+ from qev import DecisionRequest
15
+ from qev.runtime import load_model, resolve_cache, resolve_device
16
+
17
+ MAX_IMAGE_PIXELS = 16_000_000
18
+ RESOLUTIONS = (64, 128, 192, 256, 384, 512, 768, 1024)
19
+
20
+
21
+ def build_request(text, question, kind, criteria, has_image):
22
+ if len(text) > 6000 or len(question) > 1000 or len(criteria) > 6000:
23
+ raise ValueError("Please shorten the evidence, question or candidate descriptions.")
24
+ lines = [line.strip() for line in criteria.splitlines() if line.strip()]
25
+ if kind == "score":
26
+ choices = lines
27
+ elif kind in {"choice", "noul"}:
28
+ choices = {}
29
+ for line in lines:
30
+ if "|" not in line:
31
+ raise ValueError("Use one candidate per line: label | description.")
32
+ label, description = (part.strip() for part in line.split("|", 1))
33
+ if label in choices:
34
+ raise ValueError("Candidate labels must be unique.")
35
+ choices[label] = description
36
+ if kind == "noul" and set(choices) != {"false", "true"}:
37
+ raise ValueError("A noul question needs exactly the labels false and true.")
38
+ else:
39
+ raise ValueError("Choose choice, score or noul.")
40
+ return DecisionRequest.model_validate(
41
+ {
42
+ "state": {"text": text, "images": [{"path": "image.png"}] if has_image else []},
43
+ "questions": {
44
+ "decision": {"type": kind, "instructions": question, "criteria": choices}
45
+ },
46
+ }
47
+ )
48
+
49
+
50
+ def prepare_image(image, resolution):
51
+ if image is None:
52
+ return None, "No image"
53
+ if image.width * image.height > MAX_IMAGE_PIXELS:
54
+ raise ValueError("Use an image of at most 16 megapixels.")
55
+ if resolution != "Original" and int(resolution) not in RESOLUTIONS:
56
+ raise ValueError("Choose one of the available image resolutions.")
57
+ prepared = ImageOps.exif_transpose(image).convert("RGB")
58
+ source_size = prepared.size
59
+ if resolution != "Original":
60
+ edge = int(resolution)
61
+ prepared.thumbnail((edge, edge), Image.Resampling.LANCZOS)
62
+ return prepared, f"{source_size[0]} x {source_size[1]} -> {prepared.width} x {prepared.height}"
63
+
64
+
65
+ class DemoEngine:
66
+ def __init__(self, device="auto", checkpoint=None, cache_dir=None, offline=False):
67
+ self.device = resolve_device(device)
68
+ self.checkpoint = checkpoint
69
+ self.cache_dir = resolve_cache(cache_dir)
70
+ self.offline = offline or os.environ.get("QEV_OFFLINE") == "1"
71
+ self.model = None
72
+
73
+ def load(self, progress=None):
74
+ torch.set_num_threads(4)
75
+ self.model = load_model(
76
+ self.checkpoint,
77
+ device=self.device,
78
+ cache_dir=self.cache_dir,
79
+ offline=self.offline,
80
+ progress=progress,
81
+ )
82
+ return self
83
+
84
+ def predict(self, text, image, question, kind, criteria, resolution="Original"):
85
+ if self.model is None:
86
+ raise RuntimeError("The model is not ready yet. Please try again shortly.")
87
+ request = build_request(text, question, kind, criteria, image is not None)
88
+ prepared, geometry = prepare_image(image, resolution)
89
+ try:
90
+ with tempfile.TemporaryDirectory(prefix="qev-") as temporary:
91
+ if prepared is not None:
92
+ prepared.save(Path(temporary) / "image.png")
93
+ if self.device == "cuda":
94
+ torch.cuda.synchronize()
95
+ start = time.perf_counter()
96
+ result = self.model.predict(request, image_root=Path(temporary))
97
+ if self.device == "cuda":
98
+ torch.cuda.synchronize()
99
+ elapsed = (time.perf_counter() - start) * 1000
100
+ finally:
101
+ if prepared is not None:
102
+ prepared.close()
103
+ answer = result["answers"]["decision"]
104
+ probabilities = answer["probabilities"]
105
+ top = max(probabilities, key=probabilities.get)
106
+ if kind == "score":
107
+ expected = sum(float(key) * value for key, value in probabilities.items())
108
+ decision = f"Expected score: {expected:.3f} (levels start at 0)"
109
+ elif kind == "noul":
110
+ decision = f"Probability true: {probabilities['true']:.1%}"
111
+ else:
112
+ decision = f"Top candidate: {top} ({probabilities[top]:.1%})"
113
+ status = "ABSTAINED - review the evidence" if answer["abstained"] else "Answer accepted"
114
+ summary = (
115
+ f"{status}\n{decision}\n"
116
+ f"Model computation: {elapsed:.0f} ms on {self.device.upper()}\n"
117
+ f"Image: {geometry}\nQueue, upload and network time are excluded."
118
+ )
119
+ result["demo"] = {"device": self.device, "model_ms": round(elapsed, 2), "image": geometry}
120
+ # Keep schema paths as JSON text: Gradio clients otherwise interpret them as files.
121
+ return summary, probabilities, json.dumps(request.model_dump(), indent=2), result