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
82 changes: 82 additions & 0 deletions project_enigma/assumptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# The Detective's Log — Reverse-Engineered Rules

The client's note describes the format only loosely, and several test cases
contradict a literal reading of it. Working backwards from the test data, the
entire encoding collapses into one small grammar:

```
stream := package (" " package)* ; spaces separate packages
package := cycle* ; a package is a run of cycles
cycle := count value{count} ; read N, then sum the next N values
count := number ; a count of 0 (a bare '_') ends the package
value := number
number := "z"* terminator ; each 'z' = 26, terminator adds its value
terminator := <any char> ; a..z = 1..26, anything else = 0
```

The decoder is just this grammar walked left-to-right. Everything below is *why*
each rule is shaped this way.

## The core mechanic

**A "number" is a run of `z` plus one terminator.** `z` is worth 26. A number is
zero or more `z` characters (each contributing 26) followed by exactly one
terminating character, whose own value is *added*.

- `a` → 1 (empty z-run, terminator `a`)
- `zd` → 26 + 4 = 30
- `zza` → 26 + 26 + 1 = 53
- `z_` → 26 + 0 = 26 (`_` is a valid terminator, worth 0)

This matches the note's line about numbers > 26 being "multiple characters added
together, terminated with the first non-`z` character."

## Hidden rule #1 — the count repeats every cycle

The note says each package has *"a number indicating the count of values measured
in each measurement cycle,"* which sounds like **one** count per package. The data
disagrees:

- `abbcc` → `[2, 6]`: count `a`=1 → sum the next 1 value (`b`=2); count `b`=2 → sum
the next 2 values (`cc`=6).
- `abcdabcdab` → `[2, 7, 7]`: counts 1, 3, 3 with their value groups.

So the real structure is a **loop**: read a count, read that many values, emit
their sum, repeat. Each list element is one measurement cycle.

## Hidden rule #2 — `_` is just "a character worth 0"

`_` is never special-cased in the code; it is simply a character whose value is 0.
Two behaviours fall out of that automatically:

- **As a value** it contributes 0.
`aab___` → `[1, 0, 0]` (the middle `__` are two zero-valued readings).
- **As a count** it makes the count 0, which ends the package and emits a single 0.
`_ad` → `[0]` (the trailing `ad` is discarded); likewise `_zzzb` → `[0]`,
`__` → `[0]`.

Treating a 0 count as "end of package" is the single rule that covers every
underscore case without an `if ch == "_"` branch anywhere.

## Hidden rule #3 — space separates packages

This appears nowhere in the note but is forced by one pair:

- `__` → `[0]` (one zero)
- `_ _` → `[0, 0]` (two zeros)

The only difference is the space, so a space is a **package separator**: split on
space, decode each token independently, concatenate. In `_ _` the space starts a
fresh package, so the second `_` produces its own `0`.

## Edge-case handling

- **Undefined characters** (anything outside `a`–`z`, e.g. `_`): value `0`. A `0`
count ends the current package.
- **Reading past the end of the string** (a count promises more values than
remain): the missing values read as `0` rather than raising. This is what makes
`zza…` → `[26]` work — the declared count is 53, the string runs out, and the
deficit is silently zero-padded.
- **Empty string** → `[]`.
- **Multi-character counts** are allowed: a count can itself be a z-run number
(`za`=27, `zza`=53, `zd`=30), confirmed by the `za…`, `zza…` and `zd…` cases.
78 changes: 78 additions & 0 deletions project_enigma/clarifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# The Interrogation — Clarification Questions for the Client

Now that the lead engineer is back from the retreat, these are the questions I
would put to them before treating the decoder as production-ready. The current
implementation reproduces every supplied example, but the behaviour on several
items is *inferred*, not *specified* — and a few cases look mutually
inconsistent.

## 1. The meaning of the count

The note says the package starts with "a number indicating the count of values
measured in **each** measurement cycle." That implies one count per package, but
the tests (`abbcc` → `[2, 6]`) only work if a fresh count is read at the start of
every cycle.

- **Q:** Confirm the format is a repeating `count, values…` loop, not a single
leading count.

## 2. The role of the underscore `_`

`_` currently means `0` as a value but ends the package when it lands where a
count was expected:

- `aab___` → `[1, 0, 0]` (`_` as values = zeros)
- `_ad` → `[0]` (`_` as a count discards the rest, dropping `ad`)

- **Q:** Is `_` a deliberate "null / no measurement" sentinel? And is dropping
everything after a `_`-in-count-position intended, or should the parser emit `0`
and continue? `_ad` → `[0]` versus a plausible `[0, 4]` is the exact fork.

## 3. Space as a separator (undocumented)

Nothing in the note mentions spaces, but the only difference between
`__` → `[0]` and `_ _` → `[0, 0]` is a space, so I treat it as a package
separator.

- **Q:** Confirm spaces separate independent packages. How should leading/trailing
spaces, multiple consecutive spaces, or a space mid-number behave? None are in
the test set, so the current behaviour is a guess.

## 4. Numbers greater than 26

Large numbers are additive z-runs: `zz` = 52, `zza` = 53, `zd` = 30.

- **Q:** Confirm the encoding is purely additive, not positional/base-26. Is there
a maximum legal value, and what should a trailing `z` with no terminator
(a string ending in `z`) produce?

## 5. Truncated / under-length packages

`zza…` declares a count of 53 but the string is shorter, and the test expects
`[26]`. I silently treat the missing values as `0`.

- **Q:** Is zero-padding a truncated package desired, or should an input that
promises more values than it provides be rejected as malformed? In a live feed,
silent padding can mask corrupted packets.

## 6. Character set

Only `a`–`z`, `_` and space appear in the tests; everything non-alphabetic is
valued `0`.

- **Q:** What is the full legal alphabet? How should uppercase letters, digits or
other punctuation be handled — coerce to `0`, ignore, or raise?

## Cases that look contradictory, and proposed production behaviour

| Cases | Tension | Proposed rule |
|-------|---------|---------------|
| `__` → `[0]` vs `_ _` → `[0, 0]` | Identical but for a space | Make the space-as-separator rule explicit and documented, not inferred. |
| `_ad` → `[0]` vs `aab___` → `[1, 0, 0]` | `_` sometimes stops parsing, sometimes is a plain `0` | Define `_` formally as "null measurement" with one consistent rule for value vs. count position. |
| `zza…` → `[26]` (count 53, short input) | Declared length exceeds actual | Decide explicitly between "zero-pad" (current) and "reject malformed". |

For a real data feed I would recommend: an explicit documented grammar; a strict
mode that rejects truncated or out-of-alphabet packages instead of silently
emitting zeros; and a single unambiguous definition of `_`. The decoder reproduces
every supplied example today, but the items above should be confirmed before it
processes live measurements.
99 changes: 86 additions & 13 deletions project_enigma/decoding_utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,86 @@
def decode_measurements(encoded_string: str) -> list[int]:
"""This function decodes an encoded string into a list of integers.
RULE 1: A generic logic should be implemented without handling edge cases using if statements for specific inputs.
RULE 2: The generic logic should handle all inputs and generate the expected outputs.
"""
Project Enigma — The Cryptic Measurement Decoder
=================================================

A decoder for Acme Metrics Corp's measurement-package encoding.

The format, reverse-engineered from the test corpus, is a tiny grammar.
Reading it top-down explains every rule in one place:

stream := package (" " package)* ; spaces separate packages
package := cycle* ; a package is a run of cycles
cycle := count value{count} ; read N, then sum the next N
count := number ; "0" (a bare '_') ends the package
value := number
number := "z"* terminator ; each 'z' = 26, terminator adds its value
terminator := <any char> ; a..z = 1..26, anything else = 0

The whole decoder is just that grammar walked left-to-right by a Cursor.
There are no input-specific branches: '_', spaces and end-of-string all fall
out of the generic rules (an undefined char is simply worth 0).
"""

from __future__ import annotations

Z_UNIT = 26 # value carried by each 'z' in a multi-character number
PACKAGE_SEP = " " # spaces delimit independent packages


def _char_value(ch: str) -> int:
"""a..z -> 1..26; every other character (incl. '_') -> 0."""
return ord(ch) - ord("a") + 1 if "a" <= ch <= "z" else 0


class _Cursor:
"""A left-to-right reader over one package, exposing grammar primitives."""

Args:
encoded_string (str): The encoded string to decode.
def __init__(self, text: str) -> None:
self._text = text
self._pos = 0

Returns:
list[int]: The list of decoded integers.
"""
pass # Remove this pass and place your logic here to decode the string into a list of integers based on the specified encoding rules.
return [] # Placeholder return statement; replace with actual decoding logic.
@property
def at_end(self) -> bool:
return self._pos >= len(self._text)

def _peek(self) -> str | None:
return None if self.at_end else self._text[self._pos]

def _take(self) -> str:
ch = self._text[self._pos]
self._pos += 1
return ch

def read_number(self) -> int:
"""number := 'z'* terminator. Past end-of-string reads as 0."""
total = 0
while self._peek() == "z":
total += Z_UNIT
self._take()
if not self.at_end: # consume exactly one terminator
total += _char_value(self._take())
return total


def _decode_package(text: str) -> list[int]:
cursor = _Cursor(text)
measurements: list[int] = []

while not cursor.at_end:
count = cursor.read_number()
if count == 0: # a '_' where a count was expected: stop here
measurements.append(0)
break
measurements.append(sum(cursor.read_number() for _ in range(count)))

return measurements


def decode_measurements(encoded_string: str) -> list[int]:
"""Decode an Acme measurement string into its list of summed measurements."""
measurements: list[int] = []
for package in encoded_string.split(PACKAGE_SEP):
measurements.extend(_decode_package(package))
return measurements


if __name__ == "__main__":
Expand All @@ -34,7 +104,10 @@ def decode_measurements(encoded_string: str) -> list[int]:
("aab___", [1, 0, 0]),
]

passed = 0
for encoded, expected in test_cases:
result = decode_measurements(encoded)
status = "PASS" if result == expected else "FAIL"
print(f"{status}: decode_measurements({encoded!r}) = {result} (expected {expected})")
ok = result == expected
passed += ok
print(f"{'PASS' if ok else 'FAIL'}: decode_measurements({encoded!r}) = {result} (expected {expected})")
print(f"\n{passed}/{len(test_cases)} passed")