Skip to content

Commit 2c3f3a9

Browse files
fix(simtime): remove global simtime mutation from write() to ensure deterministic cross-language behavior (#385)
1 parent 8582910 commit 2c3f3a9

5 files changed

Lines changed: 144 additions & 6 deletions

File tree

concore.hpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ class Concore{
503503
outfile<<val[i]<<',';
504504
outfile<<val[val.size()-1]<<']';
505505
outfile.close();
506-
simtime += delta;
506+
// simtime must not be mutated here (issue #385).
507507
}
508508
else{
509509
throw 505;
@@ -559,7 +559,7 @@ class Concore{
559559
outfile<<val[val.size()-1]<<']';
560560
std::string result = outfile.str();
561561
std::strncpy(sharedData_create, result.c_str(), 256 - 1);
562-
simtime += delta;
562+
// simtime must not be mutated here (issue #385).
563563
}
564564
else{
565565
throw 505;

concore.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@ def write(port_identifier, name, val, delta=0):
382382
# Prepend simtime to match file-based write behavior
383383
payload = [simtime + delta] + zmq_val
384384
zmq_p.send_json_with_retry(payload)
385-
simtime += delta
385+
# simtime must not be mutated here.
386+
# Mutation breaks cross-language determinism (see issue #385).
386387
else:
387388
zmq_p.send_json_with_retry(zmq_val)
388389
except zmq.error.ZMQError as e:
@@ -413,7 +414,8 @@ def write(port_identifier, name, val, delta=0):
413414
val_converted = convert_numpy_to_python(val)
414415
data_to_write = [simtime + delta] + val_converted
415416
outfile.write(str(data_to_write))
416-
simtime += delta
417+
# simtime must not be mutated here.
418+
# Mutation breaks cross-language determinism (see issue #385).
417419
else:
418420
outfile.write(val)
419421
except Exception as e:

concore.v

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ module concore;
296296
end
297297
$fdisplay(fout,"]");
298298
$fclose(fout);
299-
simtime = simtime + delta;
299+
// simtime must not be mutated here (issue #385).
300300
end
301301
endtask
302302

concore_write.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ function concore_write(port, name, val, delta)
55
outstr = cat(2,"[",num2str(concore.simtime+delta),num2str(val,",%e"),"]");
66
fprintf(output1,'%s',outstr);
77
fclose(output1);
8-
concore.simtime = concore.simtime + delta;
8+
% simtime must not be mutated here (issue #385).
99
catch exc
1010
disp(['skipping ' concore.outpath num2str(port) '/' name]);
1111
end

tests/test_concore.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,139 @@ def recv_json_with_retry(self):
276276
# Read should return original data (simtime stripped)
277277
result = concore.read("roundtrip_test", "data", "[]")
278278
assert result == original_data
279+
280+
281+
class TestSimtimeNotMutatedByWrite:
282+
"""Regression tests for issue #385:
283+
write() must NOT mutate global simtime. Simtime advancement happens
284+
only in read() via max(simtime, file_simtime). Mutating simtime in
285+
write() causes cascading timestamps in multi-output-port nodes and
286+
breaks cross-language determinism.
287+
"""
288+
289+
@pytest.fixture(autouse=True)
290+
def reset_simtime(self):
291+
import concore
292+
old_simtime = concore.simtime
293+
yield
294+
concore.simtime = old_simtime
295+
296+
@pytest.fixture(autouse=True)
297+
def reset_zmq_ports(self):
298+
import concore
299+
original_ports = concore.zmq_ports.copy()
300+
yield
301+
concore.zmq_ports.clear()
302+
concore.zmq_ports.update(original_ports)
303+
304+
# ---- Test Case 1: single-output write keeps simtime unchanged ----
305+
306+
def test_single_file_write_does_not_mutate_simtime(self, temp_dir):
307+
"""A single file-based write with delta must not change simtime."""
308+
import concore
309+
310+
concore.simtime = 10
311+
out_dir = os.path.join(temp_dir, "out1")
312+
os.makedirs(out_dir, exist_ok=True)
313+
concore.outpath = os.path.join(temp_dir, "out")
314+
315+
concore.write(1, "v", [5.0], delta=1)
316+
317+
assert concore.simtime == 10, (
318+
"simtime must not be mutated by write(); "
319+
"was %s instead of 10" % concore.simtime
320+
)
321+
322+
def test_single_zmq_write_does_not_mutate_simtime(self):
323+
"""A single ZMQ-based write with delta must not change simtime."""
324+
import concore
325+
326+
class DummyPort:
327+
def send_json_with_retry(self, msg):
328+
self.sent = msg
329+
330+
dummy = DummyPort()
331+
concore.zmq_ports["zmq_test"] = dummy
332+
concore.simtime = 10
333+
334+
concore.write("zmq_test", "v", [5.0], delta=1)
335+
336+
assert concore.simtime == 10, (
337+
"simtime must not be mutated by ZMQ write(); "
338+
"was %s instead of 10" % concore.simtime
339+
)
340+
341+
342+
343+
def test_multi_port_file_writes_share_same_timestamp(self, temp_dir):
344+
"""Two consecutive file writes with delta=1 must produce the
345+
same timestamp (simtime+delta), proving simtime is not incremented
346+
between calls."""
347+
import concore
348+
349+
concore.simtime = 10
350+
concore.outpath = os.path.join(temp_dir, "out")
351+
for p in (1, 2):
352+
os.makedirs(os.path.join(temp_dir, "out" + str(p)), exist_ok=True)
353+
354+
concore.write(1, "u", [1.0], delta=1)
355+
concore.write(2, "v", [2.0], delta=1)
356+
357+
358+
from ast import literal_eval
359+
payloads = []
360+
for p in (1, 2):
361+
with open(os.path.join(temp_dir, "out" + str(p),
362+
("u" if p == 1 else "v"))) as f:
363+
payloads.append(literal_eval(f.read()))
364+
365+
ts1, ts2 = payloads[0][0], payloads[1][0]
366+
assert ts1 == ts2 == 11, (
367+
"Both ports must share timestamp simtime+delta=11; "
368+
"got %s and %s" % (ts1, ts2)
369+
)
370+
371+
def test_multi_port_zmq_writes_share_same_timestamp(self):
372+
"""Two consecutive ZMQ writes with delta=1 must produce the
373+
same timestamp."""
374+
import concore
375+
376+
class DummyPort:
377+
def __init__(self):
378+
self.sent = None
379+
def send_json_with_retry(self, msg):
380+
self.sent = msg
381+
382+
d1, d2 = DummyPort(), DummyPort()
383+
concore.zmq_ports["p1"] = d1
384+
concore.zmq_ports["p2"] = d2
385+
concore.simtime = 10
386+
387+
concore.write("p1", "u", [1.0], delta=1)
388+
concore.write("p2", "v", [2.0], delta=1)
389+
390+
assert d1.sent[0] == d2.sent[0] == 11, (
391+
"Both ZMQ ports must share timestamp 11; got %s and %s"
392+
% (d1.sent[0], d2.sent[0])
393+
)
394+
395+
396+
397+
def test_write_timestamp_matches_cpp_semantics(self, temp_dir):
398+
"""C++ uses `simtime+delta` as a local expression without mutation.
399+
After N writes with delta=1, simtime must still be the original
400+
value — matching C++ behaviour."""
401+
import concore
402+
403+
concore.simtime = 0
404+
concore.outpath = os.path.join(temp_dir, "out")
405+
for p in range(1, 4):
406+
os.makedirs(os.path.join(temp_dir, "out" + str(p)), exist_ok=True)
407+
408+
for p in range(1, 4):
409+
concore.write(p, "x", [float(p)], delta=1)
410+
411+
assert concore.simtime == 0, (
412+
"After 3 writes with delta=1 simtime must remain 0 "
413+
"(matching C++/MATLAB/Verilog); got %s" % concore.simtime
414+
)

0 commit comments

Comments
 (0)