426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
458 lines
16 KiB
Python
458 lines
16 KiB
Python
"""End-to-end PolarQuant validation on a Gemma text-only causal LM.
|
|
|
|
What this asserts (per the AGENTS.md mandate that we don't LARP results):
|
|
|
|
1. The script downloads/loads the ``--model`` (default ``google/gemma-4-E2B``),
|
|
quantizes it via
|
|
``polarquant_apply.quantize_checkpoint``, and serializes the result.
|
|
2. On-disk size of the PolarQuant model is meaningfully smaller than the
|
|
baseline (``> 30%`` reduction). Note: because we currently write back
|
|
the *reconstructed fp16 weights* (so the model loads with vanilla HF
|
|
``from_pretrained`` and runs on the standard linear kernels), the
|
|
primary win is the sidecar ``polarquant_artifacts.safetensors`` that
|
|
stores the int8 codes + fp16 norms — that's the artifact a downstream
|
|
INT4 inference kernel (torchao, llama.cpp, MLX) consumes. We measure
|
|
*both* on-disk sizes (model dir alone, and codes-only) and report
|
|
them. The assertion fires on the codes-only size to match how the
|
|
paper reports its 2.75x VRAM reduction.
|
|
3. Generation still produces non-degenerate tokens (the quantized model
|
|
responds to native JSON-style prompts with text that contains at least one
|
|
alphabetic word, and isn't just the EOS token or repeated punctuation).
|
|
4. We record peak inference VRAM and tokens/sec for both baseline and
|
|
quantized.
|
|
|
|
This script will only run if a CUDA GPU is present. Falls back to CPU
|
|
with a loud warning otherwise (timing numbers are then meaningless but
|
|
the correctness assertions still fire).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import json
|
|
import logging
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import torch
|
|
|
|
_HERE = Path(__file__).resolve().parent
|
|
if str(_HERE) not in sys.path:
|
|
sys.path.insert(0, str(_HERE))
|
|
|
|
from polarquant_apply import ( # type: ignore # noqa: E402
|
|
PolarQuantRecipe,
|
|
quantize_checkpoint,
|
|
)
|
|
|
|
logger = logging.getLogger("test_polarquant")
|
|
|
|
REPO_ROOT = _HERE.parent.parent
|
|
DEFAULT_VAL = REPO_ROOT / "data" / "final" / "val.jsonl"
|
|
DEFAULT_MODEL = "google/gemma-4-E2B"
|
|
DEFAULT_WORK = REPO_ROOT / "scripts" / "quantization" / ".test_polarquant_work"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sample selection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _looks_like_payload(record: dict) -> bool:
|
|
"""A record we treat as a 'native JSON message_handler-ish' sample.
|
|
|
|
We don't have the literal task type ``message_handler`` in the on-disk
|
|
val split, so we accept any record whose ``expectedResponse`` contains
|
|
the canonical native JSON keys ``thought:`` and either ``text:`` or
|
|
``actions:`` — that's the message_handler shape per
|
|
``scripts/format_for_training.py``'s ``REPLY_SYSTEM`` template.
|
|
"""
|
|
|
|
expected = str(record.get("expectedResponse") or "")
|
|
if "thought:" not in expected:
|
|
return False
|
|
return ("text:" in expected) or ("actions:" in expected)
|
|
|
|
|
|
def _load_payload_samples(path: Path, n: int) -> list[dict]:
|
|
out: list[dict] = []
|
|
with path.open(encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
rec = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if _looks_like_payload(rec):
|
|
out.append(rec)
|
|
if len(out) >= n:
|
|
break
|
|
return out
|
|
|
|
|
|
def _build_messages(record: dict) -> list[dict]:
|
|
"""Reuse the training-time chat builder so we test on the real prompt
|
|
surface, not a synthetic one."""
|
|
|
|
# Local import so this script doesn't need format_for_training in the
|
|
# path during unit-style tests.
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
from format_for_training import format_record # type: ignore
|
|
|
|
formatted = format_record(record)
|
|
if not formatted:
|
|
return []
|
|
# Drop the assistant turn — we want the model to *generate* it.
|
|
msgs = list(formatted["messages"])
|
|
if msgs and msgs[-1].get("role") == "assistant":
|
|
msgs = msgs[:-1]
|
|
return msgs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Measurement helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _dir_size_bytes(path: Path, *, exclude: Optional[set[str]] = None) -> int:
|
|
total = 0
|
|
exclude = exclude or set()
|
|
for p in path.rglob("*"):
|
|
if not p.is_file():
|
|
continue
|
|
if p.name in exclude:
|
|
continue
|
|
total += p.stat().st_size
|
|
return total
|
|
|
|
|
|
def _safetensors_size_bytes(path: Path) -> int:
|
|
return path.stat().st_size if path.exists() else 0
|
|
|
|
|
|
@dataclass
|
|
class GenStats:
|
|
label: str
|
|
peak_vram_mb: float
|
|
tokens_per_second: float
|
|
total_new_tokens: int
|
|
wall_seconds: float
|
|
sample_outputs: list[str]
|
|
|
|
|
|
def _run_generation(
|
|
model_path: Path,
|
|
tokenizer_path: Path,
|
|
samples: list[dict],
|
|
*,
|
|
label: str,
|
|
max_new_tokens: int,
|
|
device: str,
|
|
) -> GenStats:
|
|
"""Load a model from disk, run it on every sample, return timing stats."""
|
|
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
logger.info("[%s] loading model from %s", label, model_path)
|
|
tokenizer = AutoTokenizer.from_pretrained(str(tokenizer_path), trust_remote_code=True)
|
|
model = AutoModelForCausalLM.from_pretrained(
|
|
str(model_path),
|
|
torch_dtype=torch.float16,
|
|
trust_remote_code=True,
|
|
low_cpu_mem_usage=True,
|
|
)
|
|
model.to(device)
|
|
model.eval()
|
|
|
|
if device == "cuda":
|
|
torch.cuda.reset_peak_memory_stats()
|
|
torch.cuda.synchronize()
|
|
|
|
outputs: list[str] = []
|
|
n_new_tokens = 0
|
|
t0 = time.perf_counter()
|
|
|
|
for i, rec in enumerate(samples):
|
|
msgs = _build_messages(rec)
|
|
if not msgs:
|
|
continue
|
|
prompt = tokenizer.apply_chat_template(
|
|
msgs, tokenize=False, add_generation_prompt=True,
|
|
)
|
|
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=2048)
|
|
inputs = {k: v.to(device) for k, v in inputs.items()}
|
|
|
|
with torch.no_grad():
|
|
out_ids = model.generate(
|
|
**inputs,
|
|
max_new_tokens=max_new_tokens,
|
|
do_sample=False,
|
|
pad_token_id=tokenizer.eos_token_id,
|
|
)
|
|
new_ids = out_ids[0, inputs["input_ids"].shape[1]:]
|
|
n_new_tokens += int(new_ids.shape[0])
|
|
text = tokenizer.decode(new_ids, skip_special_tokens=True)
|
|
outputs.append(text)
|
|
logger.info("[%s] sample %d: %d new tokens", label, i, int(new_ids.shape[0]))
|
|
|
|
if device == "cuda":
|
|
torch.cuda.synchronize()
|
|
elapsed = time.perf_counter() - t0
|
|
peak_vram = (
|
|
torch.cuda.max_memory_allocated() / (1024 ** 2) if device == "cuda" else 0.0
|
|
)
|
|
|
|
del model
|
|
gc.collect()
|
|
if device == "cuda":
|
|
torch.cuda.empty_cache()
|
|
|
|
return GenStats(
|
|
label=label,
|
|
peak_vram_mb=peak_vram,
|
|
tokens_per_second=(n_new_tokens / elapsed) if elapsed > 0 else 0.0,
|
|
total_new_tokens=n_new_tokens,
|
|
wall_seconds=elapsed,
|
|
sample_outputs=outputs,
|
|
)
|
|
|
|
|
|
def _is_non_degenerate(text: str) -> bool:
|
|
"""Cheap garbage-detector: at least one alphabetic word and not a
|
|
pure-punctuation echo of the same character.
|
|
|
|
PolarQuant's reconstruction error is supposed to be near-lossless, so
|
|
if the quantized model emits ``!!!!!!`` or just an EOS token we want
|
|
the test to fail loudly.
|
|
"""
|
|
|
|
if not text:
|
|
return False
|
|
has_alpha_word = any(part.isalpha() and len(part) >= 3 for part in text.split())
|
|
chars = set(text.strip())
|
|
return has_alpha_word and len(chars) > 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_arg_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(description="Validate PolarQuant on gemma-4-E2B")
|
|
p.add_argument("--model", default=DEFAULT_MODEL)
|
|
p.add_argument("--val", type=Path, default=DEFAULT_VAL)
|
|
p.add_argument("--samples", type=int, default=5)
|
|
p.add_argument("--calibration-samples", type=int, default=32)
|
|
p.add_argument("--max-new-tokens", type=int, default=128)
|
|
p.add_argument("--bits", type=int, default=4)
|
|
p.add_argument("--block-size", type=int, default=128)
|
|
p.add_argument(
|
|
"--workdir",
|
|
type=Path,
|
|
default=DEFAULT_WORK,
|
|
help="Where to stage the baseline + quantized checkpoint copies.",
|
|
)
|
|
p.add_argument(
|
|
"--min-size-reduction",
|
|
type=float,
|
|
default=0.30,
|
|
help="Required fractional reduction in codes-only size to PASS.",
|
|
)
|
|
p.add_argument(
|
|
"--keep-workdir",
|
|
action="store_true",
|
|
help="Leave the staged checkpoints on disk after the run.",
|
|
)
|
|
return p
|
|
|
|
|
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
args = _build_arg_parser().parse_args(argv)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="[%(asctime)s] %(name)s %(levelname)s: %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
if not torch.cuda.is_available():
|
|
logger.warning(
|
|
"CUDA not available; running on CPU. Timing numbers will be "
|
|
"meaningless but correctness assertions still apply."
|
|
)
|
|
device = "cpu"
|
|
else:
|
|
device = "cuda"
|
|
logger.info(
|
|
"GPU: %s, %.0f MiB total",
|
|
torch.cuda.get_device_name(0),
|
|
torch.cuda.get_device_properties(0).total_memory / (1024 ** 2),
|
|
)
|
|
|
|
workdir: Path = args.workdir
|
|
baseline_dir = workdir / "baseline"
|
|
quant_dir = workdir / "polarquant"
|
|
workdir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# ---- 1. Snapshot baseline by saving the source model to disk ------
|
|
if not (baseline_dir / "config.json").exists():
|
|
logger.info("Snapshotting baseline %s -> %s", args.model, baseline_dir)
|
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
|
|
|
m = AutoModelForCausalLM.from_pretrained(
|
|
args.model, torch_dtype=torch.float16, trust_remote_code=True,
|
|
)
|
|
m.save_pretrained(str(baseline_dir), safe_serialization=True)
|
|
AutoTokenizer.from_pretrained(args.model, trust_remote_code=True).save_pretrained(
|
|
str(baseline_dir),
|
|
)
|
|
del m
|
|
gc.collect()
|
|
if device == "cuda":
|
|
torch.cuda.empty_cache()
|
|
|
|
# ---- 2. PolarQuant'd copy ------------------------------------------
|
|
if not (quant_dir / "config.json").exists():
|
|
logger.info("Quantizing %s -> %s", baseline_dir, quant_dir)
|
|
recipe = PolarQuantRecipe(
|
|
bits=args.bits, block_size=args.block_size, use_qjl=True,
|
|
)
|
|
quantize_checkpoint(
|
|
model_id_or_path=str(baseline_dir),
|
|
output_dir=quant_dir,
|
|
recipe=recipe,
|
|
device=device,
|
|
save_artifacts=True,
|
|
)
|
|
else:
|
|
logger.info("Reusing existing quantized checkpoint at %s", quant_dir)
|
|
|
|
# ---- 3. Sample selection -------------------------------------------
|
|
if not args.val.exists():
|
|
raise FileNotFoundError(f"--val not found: {args.val}")
|
|
samples = _load_payload_samples(args.val, args.samples)
|
|
if len(samples) < args.samples:
|
|
raise RuntimeError(
|
|
f"Could not find {args.samples} native JSON-shaped samples in {args.val}; "
|
|
f"found {len(samples)}.",
|
|
)
|
|
logger.info("Loaded %d native JSON samples for inference comparison", len(samples))
|
|
|
|
# ---- 4. Sizes -------------------------------------------------------
|
|
baseline_size = _dir_size_bytes(baseline_dir)
|
|
quant_model_size = _dir_size_bytes(
|
|
quant_dir, exclude={"polarquant_artifacts.safetensors"},
|
|
)
|
|
sidecar_size = _safetensors_size_bytes(
|
|
quant_dir / "polarquant_artifacts.safetensors",
|
|
)
|
|
# The codes-only "compressed model" the paper measures: sidecar +
|
|
# everything in the quantized dir EXCEPT the reconstructed
|
|
# safetensors weights (config.json, tokenizer files, generation_config,
|
|
# polarquant_config.json — all small but real).
|
|
quant_meta_size = sum(
|
|
p.stat().st_size for p in quant_dir.iterdir()
|
|
if p.is_file() and not p.name.endswith(".safetensors")
|
|
)
|
|
codes_only_size = sidecar_size + quant_meta_size
|
|
|
|
logger.info(
|
|
"Sizes: baseline=%.1fMB, quant_model=%.1fMB, sidecar=%.1fMB, "
|
|
"codes_only=%.1fMB",
|
|
baseline_size / 1e6, quant_model_size / 1e6,
|
|
sidecar_size / 1e6, codes_only_size / 1e6,
|
|
)
|
|
|
|
# ---- 5. Inference baseline + quantized -----------------------------
|
|
baseline_stats = _run_generation(
|
|
baseline_dir, baseline_dir, samples,
|
|
label="baseline_fp16",
|
|
max_new_tokens=args.max_new_tokens,
|
|
device=device,
|
|
)
|
|
quant_stats = _run_generation(
|
|
quant_dir, quant_dir, samples,
|
|
label="polarquant_q{}".format(args.bits),
|
|
max_new_tokens=args.max_new_tokens,
|
|
device=device,
|
|
)
|
|
|
|
# ---- 6. Assertions -------------------------------------------------
|
|
failures: list[str] = []
|
|
|
|
# Size win — measured on codes-only payload (the actual paper claim).
|
|
size_reduction = (
|
|
1.0 - (codes_only_size / baseline_size) if baseline_size else 0.0
|
|
)
|
|
if size_reduction < args.min_size_reduction:
|
|
failures.append(
|
|
f"size reduction {size_reduction:.1%} below threshold "
|
|
f"{args.min_size_reduction:.0%}",
|
|
)
|
|
|
|
# Output sanity — every quantized output has to be non-degenerate.
|
|
for i, text in enumerate(quant_stats.sample_outputs):
|
|
if not _is_non_degenerate(text):
|
|
failures.append(f"sample {i} produced degenerate output: {text!r}")
|
|
|
|
summary = {
|
|
"model": args.model,
|
|
"bits": args.bits,
|
|
"block_size": args.block_size,
|
|
"device": device,
|
|
"n_samples": len(samples),
|
|
"sizes_mb": {
|
|
"baseline_dir": round(baseline_size / 1e6, 2),
|
|
"quantized_dir_recon_only": round(quant_model_size / 1e6, 2),
|
|
"polarquant_sidecar": round(sidecar_size / 1e6, 2),
|
|
"codes_only_payload": round(codes_only_size / 1e6, 2),
|
|
},
|
|
"size_reduction_codes_only_pct": round(size_reduction * 100, 2),
|
|
"inference": {
|
|
"baseline_fp16": {
|
|
"peak_vram_mb": round(baseline_stats.peak_vram_mb, 1),
|
|
"tok_per_sec": round(baseline_stats.tokens_per_second, 2),
|
|
"total_new_tokens": baseline_stats.total_new_tokens,
|
|
"wall_seconds": round(baseline_stats.wall_seconds, 2),
|
|
},
|
|
f"polarquant_q{args.bits}": {
|
|
"peak_vram_mb": round(quant_stats.peak_vram_mb, 1),
|
|
"tok_per_sec": round(quant_stats.tokens_per_second, 2),
|
|
"total_new_tokens": quant_stats.total_new_tokens,
|
|
"wall_seconds": round(quant_stats.wall_seconds, 2),
|
|
},
|
|
},
|
|
"sample_outputs": {
|
|
"baseline_first": baseline_stats.sample_outputs[0][:400] if baseline_stats.sample_outputs else "",
|
|
"polarquant_first": quant_stats.sample_outputs[0][:400] if quant_stats.sample_outputs else "",
|
|
},
|
|
"assertions": {
|
|
"passed": not failures,
|
|
"failures": failures,
|
|
},
|
|
}
|
|
|
|
print(json.dumps(summary, indent=2))
|
|
|
|
if not args.keep_workdir:
|
|
# Leave directories so a re-run can reuse the snapshots; the user
|
|
# explicitly opts in via --keep-workdir for the verbose case.
|
|
pass
|
|
|
|
if failures:
|
|
logger.error("PolarQuant validation FAILED: %s", "; ".join(failures))
|
|
return 1
|
|
logger.info("PolarQuant validation PASSED")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|