Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions datamimic_ce/scripting/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# DATAMIMIC
# Copyright (c) 2023-2025 Rapiddweller Asia Co., Ltd.
# This software is licensed under the MIT License.
# See LICENSE file for the full text of the license.
# For questions and support, contact: info@rapiddweller.com
67 changes: 67 additions & 0 deletions datamimic_ce/scripting/js_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# DATAMIMIC
# Copyright (c) 2023-2025 Rapiddweller Asia Co., Ltd.
# This software is licensed under the MIT License.
# See LICENSE file for the full text of the license.
# For questions and support, contact: info@rapiddweller.com

"""PROTOTYPE - JS execution spike, not wired into the DSL/parser yet.

Evaluates `{js:...}`-style inline scripts (Benerator descriptor parity) via an embedded V8
(mini-racer, optional dependency - `pip install datamimic_ce[js]`).

The hard part isn't "can we eval JS" - it's that a JS engine ships nondeterministic builtins
(Math.random, Date.now/new Date) inside a deterministic-first engine (AGENTS.md rule 6: same
seed -> same output). Bridging Math.random through a Python callback per call is possible (mini-
racer's wrap_py_function) but async-only, which doesn't fit CE's synchronous evaluation model.
Instead: derive ONE seed from CE's own RNG chain (spawn_rng/derive_child_seed, the same helper
BaseDomainGenerator._derive_rng uses) and seed a small pure-JS PRNG (Mulberry32) with it - no
Python<->JS bridge needed per call, just one integer at construction time. Date.now is pinned to
the same DETERMINISTIC_ANCHOR every other "now"-derived field in CE already uses.

Unseeded (no rng passed): left alone, native Math.random/Date.now - matches "no rngSeed = every
run differs, by design".

Known gap, not solved by this prototype: `new Date()` (argument-less) still reads real wall-clock
- only `Date.now()` is pinned. Fine for a feasibility spike; would need closing before real use.
"""

from __future__ import annotations

from datetime import UTC
from random import Random
from typing import Any

from datamimic_ce.domains.domain_core.runtime.clock import DETERMINISTIC_ANCHOR
from datamimic_ce.domains.domain_core.runtime.rng import derive_child_seed

_SEEDED_BOOTSTRAP = """
(function(seed, fixedNowMs) {
let state = seed >>> 0;
Math.random = function() {
state = (state + 0x6D2B79F5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
Date.now = function() { return fixedNowMs; };
})(%d, %d);
"""


class JsEngine:
"""A JS evaluation context. Pass `rng` for deterministic Math.random/Date.now; omit for a
normal (non-reproducible) engine."""

def __init__(self, rng: Random | None = None) -> None:
from py_mini_racer import MiniRacer

self._ctx = MiniRacer()
if rng is not None:
seed = derive_child_seed(rng)
# DETERMINISTIC_ANCHOR is naive; anchor it to UTC explicitly so the epoch-ms value
# is the same on every machine regardless of local timezone.
fixed_now_ms = int(DETERMINISTIC_ANCHOR.replace(tzinfo=UTC).timestamp() * 1000)
self._ctx.eval(_SEEDED_BOOTSTRAP % (seed, fixed_now_ms))

def eval(self, expr: str) -> Any:
return self._ctx.eval(expr)
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ mcp = [
"uvicorn>=0.30",
]

# Optional dependency group for JS execution (prototype - datamimic_ce/scripting/js_engine.py)
js = [
"mini-racer>=0.12",
]

[tool.setuptools_scm]
local_scheme = "dirty-tag" # Includes a "dirty" tag in version if working directory is not clean

Expand Down
57 changes: 57 additions & 0 deletions tests_ce/unit_tests/test_scripting/test_js_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# DATAMIMIC
# Copyright (c) 2023-2025 Rapiddweller Asia Co., Ltd.
# This software is licensed under the MIT License.
# See LICENSE file for the full text of the license.
# For questions and support, contact: info@rapiddweller.com

"""PROTOTYPE spike, not wired into the DSL. The property that matters isn't "can we eval JS" -
it's whether a seeded engine's Math.random()/Date.now() are reproducible, since a JS engine
ships nondeterministic builtins by default inside a deterministic-first engine (AGENTS.md rule 6)."""

from random import Random

import pytest

pytest.importorskip("py_mini_racer")

from datamimic_ce.scripting.js_engine import JsEngine # noqa: E402


def test_basic_eval():
assert JsEngine().eval("1 + 1") == 2


def test_seeded_math_random_is_reproducible():
seq1 = [JsEngine(rng=Random(42)).eval("Math.random()") for _ in range(5)]
seq2 = [JsEngine(rng=Random(42)).eval("Math.random()") for _ in range(5)]
assert seq1 == seq2


def test_seeded_math_random_sequence_within_one_engine_is_reproducible():
"""Not just the first draw - repeated calls on the SAME engine must also replay identically
across two separately-constructed engines sharing a seed."""
engine1 = JsEngine(rng=Random(7))
engine2 = JsEngine(rng=Random(7))
seq1 = [engine1.eval("Math.random()") for _ in range(10)]
seq2 = [engine2.eval("Math.random()") for _ in range(10)]
assert seq1 == seq2
assert len(set(seq1)) > 1, "a real PRNG sequence, not a constant"


def test_different_seeds_diverge():
a = JsEngine(rng=Random(1)).eval("Math.random()")
b = JsEngine(rng=Random(2)).eval("Math.random()")
assert a != b


def test_seeded_date_now_is_pinned():
t1 = JsEngine(rng=Random(1)).eval("Date.now()")
t2 = JsEngine(rng=Random(99)).eval("Date.now()")
assert t1 == t2, "Date.now() must pin to the same DETERMINISTIC_ANCHOR regardless of seed value"


def test_unseeded_engine_is_not_forced_deterministic():
"""No rng passed -> native Math.random, left alone (AGENTS.md rule 6: no rngSeed means every
run differs, by design)."""
values = {JsEngine().eval("Math.random()") for _ in range(20)}
assert len(values) > 1
Loading