Merge pull request 'visualNKode' (#3) from visualNKode into main

Reviewed-on: https://git.infra.nkode.tech/dkelly/evilnkode/pulls/3
This commit is contained in:
dkelly
2025-08-19 16:06:33 +00:00
59 changed files with 4169 additions and 111 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
.DS_Store
output
__pycache__
.ipynb_checkpoints

0
__init__.py Normal file
View File

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,82 +1,17 @@
from src.evilkode import Observation, Evilkode
from src.keypad import Keypad
import random
from src.evilkode import Evilkode
from dataclasses import dataclass
from statistics import mean, variance
from enum import Enum
from pathlib import Path
from src.utils import ShuffleTypes, observations, passcode_generator
@dataclass
class Benchmark:
mean: int
variance: int
runs: list[int]
class ShuffleTypes(Enum):
FULL_SHUFFLE = "FULL_SHUFFLE"
SPLIT_SHUFFLE = "SPLIT_SHUFFLE"
def observations(number_of_keys, properties_per_key, passcode_len, complexity: int, disparity: int, shuffle_type: ShuffleTypes):
k = number_of_keys
p = properties_per_key
n = passcode_len
passcode = passcode_generator(k, p, n, complexity, disparity)
keypad = Keypad.new_keypad(k, p)
def obs_gen():
for _ in range(100): # finite number of yields
yield Observation(
keypad=keypad.keypad.copy(),
key_selection=keypad.key_entry(target_passcode=passcode)
)
match shuffle_type:
case ShuffleTypes.FULL_SHUFFLE:
keypad.full_shuffle()
case ShuffleTypes.SPLIT_SHUFFLE:
keypad.split_shuffle()
case _:
raise Exception(f"no shuffle type {shuffle_type}")
return obs_gen()
def passcode_generator(k: int, p: int, n: int, c: int, d: int) -> list[int]:
assert n >= c
assert p*k >= c
assert n >= d
assert p >= d
passcode_prop = []
passcode_set = []
valid_choices = {i for i in range(k*p)}
repeat_set = n-d
repeat_prop = n-c
prop_added = set()
set_added = set()
for _ in range(n):
prop = random.choice(list(valid_choices))
prop_set = prop//p
passcode_prop.append(prop)
passcode_set.append(prop_set)
if prop in prop_added:
repeat_prop -= 1
if prop_set in set_added:
repeat_set -= 1
prop_added.add(prop)
set_added.add(prop_set)
if repeat_prop <= 0:
valid_choices -= prop_added
if repeat_set <= 0:
for el in valid_choices.copy():
if el // p in set_added:
valid_choices.remove(el)
return passcode_prop
def shuffle_benchmark(
number_of_keys: int,
@@ -94,7 +29,6 @@ def shuffle_benchmark(
full_path = Path(file_path) / file_name
if not overwrite and full_path.exists():
print(f"file exists {file_path}")
with open(full_path, "r") as fp:
runs = fp.readline()
runs = runs.split(',')
@@ -106,13 +40,14 @@ def shuffle_benchmark(
)
runs = []
for _ in range(run_count):
passcode = passcode_generator(number_of_keys, properties_per_key, passcode_len, complexity, disparity)
evilkode = Evilkode(
observations=observations(
target_passcode=passcode,
number_of_keys=number_of_keys,
properties_per_key=properties_per_key,
passcode_len=passcode_len,
complexity=complexity,
disparity=disparity,
min_complexity=complexity,
min_disparity=disparity,
shuffle_type=shuffle_type,
),
number_of_keys=number_of_keys,
@@ -145,13 +80,14 @@ def full_shuffle_benchmark(
) -> Benchmark:
runs = []
for _ in range(run_count):
passcode = passcode_generator(number_of_keys, properties_per_key, passcode_len, complexity, disparity)
evilkode = Evilkode(
observations=observations(
target_passcode=passcode,
number_of_keys=number_of_keys,
properties_per_key=properties_per_key,
passcode_len=passcode_len,
complexity=complexity,
disparity=disparity,
min_complexity=complexity,
min_disparity=disparity,
shuffle_type=ShuffleTypes.FULL_SHUFFLE,
),
number_of_keys=number_of_keys,

View File

@@ -44,5 +44,4 @@ class Evilkode:
return EvilOutput(possible_nkodes=[list(el) for el in self.possible_nkode], iterations=idx+1)
for jdx, props in enumerate(obs.property_list):
self.possible_nkode[jdx] = props.intersection(self.possible_nkode[jdx])
raise Exception("error in Evilkode, observations stopped yielding")

View File

@@ -1,7 +1,79 @@
import random
from enum import Enum
from math import factorial, comb
from src.evilkode import Observation
from src.keypad import Keypad
def total_valid_nkode_states(k: int, p: int) -> int:
return factorial(k) ** (p-1)
def total_shuffle_states(k: int, p: int) -> int:
return comb((p-1), (p-1) // 2) * factorial(k)
class ShuffleTypes(Enum):
FULL_SHUFFLE = "FULL_SHUFFLE"
SPLIT_SHUFFLE = "SPLIT_SHUFFLE"
def observations(target_passcode: list[int], number_of_keys:int, properties_per_key: int, min_complexity: int, min_disparity: int, shuffle_type: ShuffleTypes, number_of_observations: int = 100):
k = number_of_keys
p = properties_per_key
keypad = Keypad.new_keypad(k, p)
def obs_gen():
for _ in range(number_of_observations):
yield Observation(
keypad=keypad.keypad.copy(),
key_selection=keypad.key_entry(target_passcode=target_passcode)
)
match shuffle_type:
case ShuffleTypes.FULL_SHUFFLE:
keypad.full_shuffle()
case ShuffleTypes.SPLIT_SHUFFLE:
keypad.split_shuffle()
case _:
raise Exception(f"no shuffle type {shuffle_type}")
return obs_gen()
def passcode_generator(k: int, p: int, n: int, c: int, d: int) -> list[int]:
assert n >= c
assert p*k >= c
assert n >= d
assert p >= d
passcode_prop = []
passcode_set = []
valid_choices = {i for i in range(k*p)}
repeat_set = n-d
repeat_prop = n-c
prop_added = set()
set_added = set()
for _ in range(n):
prop = random.choice(list(valid_choices))
prop_set = prop//p
passcode_prop.append(prop)
passcode_set.append(prop_set)
if prop in prop_added:
repeat_prop -= 1
if prop_set in set_added:
repeat_set -= 1
prop_added.add(prop)
set_added.add(prop_set)
if repeat_prop <= 0:
valid_choices -= prop_added
if repeat_set <= 0:
for el in valid_choices.copy():
if el // p in set_added:
valid_choices.remove(el)
return passcode_prop

229
src/visualnkode.py Normal file
View File

@@ -0,0 +1,229 @@
import json
from dataclasses import dataclass, asdict
from evilkode import Observation
from utils import observations, passcode_generator, ShuffleTypes
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from typing import Iterable
# Project root = parent of *this* file's directory
PROJECT_ROOT = Path(__file__).resolve().parent.parent
OUTPUT_DIR = PROJECT_ROOT / "example" / "obs_json"
PNG_DIR = PROJECT_ROOT / "example" / "obs_png"
@dataclass
class ObservationSequence:
target_passcode: list[int]
observations: list[Observation]
def new_observation_sequence(
number_of_keys: int,
properties_per_key: int,
passcode_len: int,
complexity: int,
disparity: int,
numb_runs: int,
) -> ObservationSequence:
passcode = passcode_generator(number_of_keys, properties_per_key, passcode_len, complexity, disparity)
obs_seq = ObservationSequence(target_passcode=passcode, observations=[])
obs_gen = observations(
target_passcode=passcode,
number_of_keys=number_of_keys,
properties_per_key=properties_per_key,
min_complexity=complexity,
min_disparity=disparity,
shuffle_type=ShuffleTypes.FULL_SHUFFLE,
number_of_observations=numb_runs,
)
for obs in obs_gen:
obs.keypad = obs.keypad.tolist()
obs_seq.observations.append(obs)
return obs_seq
def _next_json_filename(base_dir: Path) -> Path:
"""Find the next available observation_X.json file in base_dir."""
counter = 1
while True:
candidate = base_dir / f"observation_{counter}.json"
if not candidate.exists():
return candidate
counter += 1
def save_observation_sequence_to_json(seq: ObservationSequence, filename: Path | None = None) -> None:
"""
Save ObservationSequence to JSON.
- If filename is None, put it under PROJECT_ROOT/output/obs_json/ as observation_{n}.json
- Creates directory if needed
"""
if filename is None:
base_dir = OUTPUT_DIR
base_dir.mkdir(parents=True, exist_ok=True)
filename = _next_json_filename(base_dir)
else:
filename.parent.mkdir(parents=True, exist_ok=True)
with filename.open("w", encoding="utf-8") as f:
json.dump(asdict(seq), f, indent=4)
# ---------- Helpers ----------
def _load_font(preferred: str, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""Try a preferred TTF, fall back to common monospace, then PIL default."""
candidates = [
preferred,
"DejaVuSansMono.ttf", # common on Linux
"Consolas.ttf", # Windows
"Menlo.ttc", "Menlo.ttf", # macOS
"Courier New.ttf",
]
for c in candidates:
try:
return ImageFont.truetype(c, size)
except Exception:
continue
return ImageFont.load_default()
def _text_size(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
"""Get (w, h) using font bbox for accurate layout."""
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
return right - left, bottom - top
def _join_nums(nums: Iterable[int]) -> str:
return " ".join(str(n) for n in nums)
def _next_available_path(path: Path) -> Path:
"""If path exists, append _1, _2, ..."""
if not path.exists():
return path
base, suffix = path.stem, path.suffix or ".png"
i = 1
while True:
candidate = path.with_name(f"{base}_{i}{suffix}")
if not candidate.exists():
return candidate
i += 1
# ---------- Core rendering ----------
def render_observation_to_png(
target_passcode: list[int],
obs: Observation,
out_path: Path,
*,
header_font_name: str = "DejaVuSans.ttf",
body_font_name: str = "DejaVuSans.ttf",
header_size: int = 28,
body_size: int = 24,
margin: int = 32,
row_padding_xy: tuple[int, int] = (16, 12), # (x, y) padding inside row box
row_spacing: int = 14,
header_spacing: int = 10,
section_spacing: int = 18,
bg_color: str = "white",
fg_color: str = "black",
row_fill: str = "#f7f7f7",
row_outline: str = "#222222",
):
"""
Render a single observation:
- Top lines:
Target Passcode: {target}
Selected Keys: {selected keys}
- Then a stack of row boxes representing the keypad rows.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path = _next_available_path(out_path)
# Fonts
header_font = _load_font(header_font_name, header_size)
body_font = _load_font(body_font_name, body_size)
# Prepare strings
header1 = f"Target Passcode: {_join_nums(target_passcode)}"
header2 = f"Selected Keys: {_join_nums(obs.key_selection)}"
row_texts = [_join_nums(row) for row in obs.keypad]
# Measure to compute canvas size
# Provisional image for measurement
temp_img = Image.new("RGB", (1, 1), bg_color)
d = ImageDraw.Draw(temp_img)
h1_w, h1_h = _text_size(d, header1, header_font)
h2_w, h2_h = _text_size(d, header2, header_font)
row_text_sizes = [_text_size(d, t, body_font) for t in row_texts]
row_box_widths = [tw + 2 * row_padding_xy[0] for (tw, th) in row_text_sizes]
row_box_heights = [th + 2 * row_padding_xy[1] for (tw, th) in row_text_sizes]
content_width = max([h1_w, h2_w] + (row_box_widths or [0]))
total_rows_height = sum(row_box_heights) + row_spacing * max(0, len(row_box_heights) - 1)
width = content_width + 2 * margin
height = (
margin
+ h1_h
+ header_spacing
+ h2_h
+ section_spacing
+ total_rows_height
+ margin
)
# Create final image
img = Image.new("RGB", (max(width, 300), max(height, 200)), bg_color)
draw = ImageDraw.Draw(img)
# Draw headers
x = margin
y = margin
draw.text((x, y), header1, font=header_font, fill=fg_color)
y += h1_h + header_spacing
draw.text((x, y), header2, font=header_font, fill=fg_color)
y += h2_h + section_spacing
# Draw row boxes
for (text, (tw, th), box_h) in zip(row_texts, row_text_sizes, row_box_heights):
box_left = x
box_top = y
box_right = x + max(row_box_widths) # make all boxes same width for neatness
box_bottom = y + box_h
# rectangle
draw.rectangle([box_left, box_top, box_right, box_bottom], fill=row_fill, outline=row_outline, width=2)
# text centered vertically, left-padded
text_x = box_left + row_padding_xy[0]
text_y = box_top + (box_h - th) // 2
draw.text((text_x, text_y), text, font=body_font, fill=fg_color)
y = box_bottom + row_spacing
img.save(out_path, format="PNG")
def _next_run_dir(base_dir: Path) -> Path:
"""Find the next available run directory under base_dir (run_001, run_002, ...)."""
counter = 1
while True:
run_dir = base_dir / f"run_{counter:03d}"
if not run_dir.exists():
run_dir.mkdir(parents=True)
return run_dir
counter += 1
def render_sequence_to_pngs(seq: ObservationSequence, out_dir: Path | None = None) -> None:
"""
Render each observation to its own PNG inside a fresh run directory.
Default: PROJECT_ROOT/output/obs_png/run_XXX/observation_001.png
"""
base_dir = PNG_DIR if out_dir is None else out_dir
base_dir.mkdir(parents=True, exist_ok=True)
# Create a fresh run dir
run_dir = _next_run_dir(base_dir)
for i, obs in enumerate(seq.observations, start=1):
filename = run_dir / f"observation_{i:03d}.png"
render_observation_to_png(seq.target_passcode, obs, filename)
if __name__ == "__main__":
obs_seq = new_observation_sequence(6, 9,4,0,0,numb_runs=50)
save_observation_sequence_to_json(obs_seq)
render_sequence_to_pngs(obs_seq)

View File

@@ -1,4 +1,4 @@
from src.benchmark import passcode_generator
from src.utils import passcode_generator
import pytest
@pytest.mark.parametrize(