<!-- canonical: https://efficientnewlanguage.org/eml-p/projects/P123-inventory-diff/ | updated: 2026-10-17 -->

# P123 Inventory diff

Two stock counts compared in both directions: added, gone, changed and renamed items, an item very likely moved to a new code, a units balance, and what a one-way check never sees.

EML-P project `projects/inventory-diff` in the EML language repo: 2 module(s), entry `main.eml`, terminal UI. There, `eml project run projects/inventory-diff` runs it and `eml project verify projects/inventory-diff` replays every session under CPython (two hash seeds) and in the interpreter; the site build replays every session in the interpreter again and publishes a session only if its screen matches.

Built on verified corpus cases: inventory-diff-report (https://efficientnewlanguage.org/cases/174-inventory-diff-report/), dict-inventory-report (https://efficientnewlanguage.org/cases/142-dict-inventory-report/).

## Sessions

### bad-input - interpreter: byte-equal to the golden

Input:

```text
0
x
4

4
d400
4
D40
4
DD400
4
D400

4
D400
x
4
D400
100000
4
D400
-3
4
Z999

4
Z999
Tape
4
Z999
a very long name indeed yes
4
Z999
tape

4
Z999
tape
remove
4
Z999
tape 2
4
Z999
masking tape
7
2
5
```

Screen:

```text
== Inventory diff ==
Two stock counts, last month's and this month's, compared in both directions.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 0
Pick a number from 1 to 5.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> x
Pick a number from 1 to 5.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> 
Cancelled.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> d400
Type a capital letter and 3 digits, such as D400.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> D40
Type a capital letter and 3 digits, such as D400.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> DD400
Type a capital letter and 3 digits, such as D400.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> D400
quantity (0 to 99999, or remove)> 
Cancelled.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> D400
quantity (0 to 99999, or remove)> x
Type a whole number from 0 to 99999, or remove.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> D400
quantity (0 to 99999, or remove)> 100000
Type a whole number from 0 to 99999, or remove.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> D400
quantity (0 to 99999, or remove)> -3
Type a whole number from 0 to 99999, or remove.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> 
Cancelled.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> Tape
Type up to 20 lowercase letters and spaces.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> a very long name indeed yes
Type up to 20 lowercase letters and spaces.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> tape
quantity (0 to 99999, or remove)> 
Cancelled.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> tape
quantity (0 to 99999, or remove)> remove
Z999 is not on the new count.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> tape 2
Type up to 20 lowercase letters and spaces.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> Z999
name (up to 20 lowercase letters and spaces)> masking tape
quantity (0 to 99999, or remove)> 7
Z999 masking tape added to the new count, 7.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 2
Differences, before to now:
  added:   B205 brackets 12
  added:   D400 tape 30
  added:   Z999 masking tape 7
  gone:    B201 brackets 12
  gone:    C301 sealant 5
  changed: A101 nuts 300 -> 260 (-40)
  changed: C300 glue 9 -> 0 (-9)
           C300 is still on the list, at 0: counted and empty, not gone.
  renamed: B200 hinges -> door hinges
  the same: 2 items
Very likely one item under a new code: B201 brackets is gone and B205 brackets added, with the same name and quantity (12).
Units: 561 before, 544 now: +49 added, -17 gone, -49 on the items in both.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 5
Bye.
```

### basic - interpreter: byte-equal to the golden

Input:

```text
1
2
3
4
B205
remove
4
B201
brackets
12
2
4
C300
4
2
3
5
```

Screen:

```text
== Inventory diff ==
Two stock counts, last month's and this month's, compared in both directions.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 1
  code  before                now
  A100  bolts 120             bolts 120
  A101  nuts 300              nuts 260
  A102  washers 75            washers 75
  B200  hinges 40             door hinges 40
  B201  brackets 12           -
  B205  -                     brackets 12
  C300  glue 9                glue 0
  C301  sealant 5             -
  D400  -                     tape 30
  7 items before, 7 now.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 2
Differences, before to now:
  added:   B205 brackets 12
  added:   D400 tape 30
  gone:    B201 brackets 12
  gone:    C301 sealant 5
  changed: A101 nuts 300 -> 260 (-40)
  changed: C300 glue 9 -> 0 (-9)
           C300 is still on the list, at 0: counted and empty, not gone.
  renamed: B200 hinges -> door hinges
  the same: 2 items
Very likely one item under a new code: B201 brackets is gone and B205 brackets added, with the same name and quantity (12).
Units: 561 before, 537 now: +42 added, -17 gone, -49 on the items in both.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 3
Checking each item now against before finds B205, D400, A101 and C300 - and never what is gone: B201 and C301.
Checking each item before against now finds B201, C301, A101 and C300 - and never what is new: B205 and D400.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> B205
quantity (0 to 99999, or remove)> remove
B205 brackets taken off the new count.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> B201
name (up to 20 lowercase letters and spaces)> brackets
quantity (0 to 99999, or remove)> 12
B201 brackets added to the new count, 12.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 2
Differences, before to now:
  added:   D400 tape 30
  gone:    C301 sealant 5
  changed: A101 nuts 300 -> 260 (-40)
  changed: C300 glue 9 -> 0 (-9)
           C300 is still on the list, at 0: counted and empty, not gone.
  renamed: B200 hinges -> door hinges
  the same: 3 items
Units: 561 before, 537 now: +30 added, -5 gone, -49 on the items in both.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 4
code (a capital letter and 3 digits, such as D400)> C300
quantity (0 to 99999, or remove)> 4
C300 glue: 0 -> 4.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 2
Differences, before to now:
  added:   D400 tape 30
  gone:    C301 sealant 5
  changed: A101 nuts 300 -> 260 (-40)
  changed: C300 glue 9 -> 4 (-5)
  renamed: B200 hinges -> door hinges
  the same: 3 items
Units: 561 before, 541 now: +30 added, -5 gone, -45 on the items in both.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 3
Checking each item now against before finds D400, A101 and C300 - and never what is gone: C301.
Checking each item before against now finds C301, A101 and C300 - and never what is new: D400.

1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit
choice> 5
Bye.
```

## Modules

### main.eml

```eml
# P123 inventory diff: two stock counts side by side, the differences in
# both directions, what a one-way check misses, and changes to the new
# count.
import stock

def plain(s):
    0 => i
    len(s) => j
    while i < j and s[i] == " ":
        i + 1 => i
    while j > i and s[j - 1] == " ":
        j - 1 => j
    return s[i:j]

def pad(s, n):
    s => out
    while len(out) < n:
        out + " " => out
    return out

def lpad(s, n):
    s => out
    while len(out) < n:
        " " + out => out
    return out

def names_text(ns):
    "" => out
    for i in [0:len(ns) - 1]:
        if i > 0 and i == len(ns) - 1:
            out + " and " => out
        elif i > 0:
            out + ", " => out
        out + ns[i] => out
    return out

def signed(n):
    if n > 0:
        return "+" + str(n)
    return str(n)

def item_text(count, code):
    return code + " " + count[code][0] + " " + str(count[code][1])

def show(before, now):
    "  code  before                now" ^0
    for code in all_codes(before, now):
        "-" => a
        "-" => b
        if code in before:
            before[code][0] + " " + str(before[code][1]) => a
        if code in now:
            now[code][0] + " " + str(now[code][1]) => b
        ("  " + code + "  " + pad(a, 22) + b) ^0
    ("  " + str(len(before)) + " items before, " + str(len(now)) + " now.") ^0

def all_codes(before, now):
    {} => both
    for c in before:
        1 => both[c]
    for c in now:
        1 => both[c]
    return stock.sorted_codes(both)

def differences(before, now):
    stock.diff(before, now) => d
    "Differences, before to now:" ^0
    if len(d["added"]) == 0:
        "  added:   none" ^0
    for code in d["added"]:
        ("  added:   " + item_text(now, code)) ^0
    if len(d["gone"]) == 0:
        "  gone:    none" ^0
    for code in d["gone"]:
        ("  gone:    " + item_text(before, code)) ^0
    for code in d["changed"]:
        now[code][1] - before[code][1] => delta
        ("  changed: " + code + " " + now[code][0] + " " + str(before[code][1]) + " -> " + str(now[code][1]) + " (" + signed(delta) + ")") ^0
        if now[code][1] == 0:
            ("           " + code + " is still on the list, at 0: counted and empty, not gone.") ^0
    for code in d["renamed"]:
        ("  renamed: " + code + " " + before[code][0] + " -> " + now[code][0]) ^0
    ("  the same: " + str(len(d["same"])) + " items") ^0
    for p in stock.moved(before, now, d):
        ("Very likely one item under a new code: " + p[0] + " " + before[p[0]][0] + " is gone and " + p[1] + " " + now[p[1]][0] + " added, with the same name and quantity (" + str(now[p[1]][1]) + ").") ^0
    stock.balance(before, now, d) => b
    ("Units: " + str(b[0]) + " before, " + str(b[1]) + " now: " + signed(b[2]) + " added, " + signed(0 - b[3]) + " gone, " + signed(b[4]) + " on the items in both.") ^0

def one_way(before, now):
    stock.diff(before, now) => d
    [] => found
    for code in d["added"]:
        found + [code] => found
    for code in d["changed"]:
        found + [code] => found
    ("Checking each item now against before finds " + list_or_none(found) + " - and never what is gone: " + list_or_none(d["gone"]) + ".") ^0
    [] => found2
    for code in d["gone"]:
        found2 + [code] => found2
    for code in d["changed"]:
        found2 + [code] => found2
    ("Checking each item before against now finds " + list_or_none(found2) + " - and never what is new: " + list_or_none(d["added"]) + ".") ^0

def list_or_none(xs):
    if len(xs) == 0:
        return "nothing"
    return names_text(xs)

def good_code(c):
    if len(c) != 4 or c[0] < "A" or c[0] > "Z":
        return False
    for k in [1:3]:
        if c[k] < "0" or c[k] > "9":
            return False
    return True

def ask_qty():
    # the quantity, -1 to cancel or refuse, -2 to remove
    plain(input("quantity (0 to 99999, or remove)> ")) => t
    if t == "":
        "Cancelled." ^0
        return 0 - 1
    if t == "remove":
        return 0 - 2
    if len(t) == 0 or len(t) > 5:
        "Type a whole number from 0 to 99999, or remove." ^0
        return 0 - 1
    for c in t:
        if c < "0" or c > "9":
            "Type a whole number from 0 to 99999, or remove." ^0
            return 0 - 1
    return int(t)

def change(now):
    plain(input("code (a capital letter and 3 digits, such as D400)> ")) => code
    if code == "":
        "Cancelled." ^0
        return now
    if not good_code(code):
        "Type a capital letter and 3 digits, such as D400." ^0
        return now
    if code in now:
        ask_qty() => q
        if q == 0 - 1:
            return now
        if q == 0 - 2:
            (code + " " + now[code][0] + " taken off the new count.") ^0
            return stock.remove_item(now, code)
        (code + " " + now[code][0] + ": " + str(now[code][1]) + " -> " + str(q) + ".") ^0
        return stock.set_item(now, code, now[code][0], q)
    plain(input("name (up to 20 lowercase letters and spaces)> ")) => name
    if name == "":
        "Cancelled." ^0
        return now
    if len(name) > 20:
        "Type up to 20 lowercase letters and spaces." ^0
        return now
    for c in name:
        if not ((c >= "a" and c <= "z") or c == " "):
            "Type up to 20 lowercase letters and spaces." ^0
            return now
    ask_qty() => q
    if q == 0 - 1:
        return now
    if q == 0 - 2:
        (code + " is not on the new count.") ^0
        return now
    (code + " " + name + " added to the new count, " + str(q) + ".") ^0
    return stock.set_item(now, code, name, q)

stock.before_count() => before
stock.now_count() => now

"== Inventory diff ==" ^0
"Two stock counts, last month's and this month's, compared in both directions." ^0
True => running
while running:
    "" ^0
    "1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit" ^0
    plain(input("choice> ")) => choice
    if choice == "1":
        show(before, now)
    elif choice == "2":
        differences(before, now)
    elif choice == "3":
        one_way(before, now)
    elif choice == "4":
        change(now) => now
    elif choice == "5":
        False => running
    else:
        "Pick a number from 1 to 5." ^0
"Bye." ^0
```

Python projection of main.eml:

```python
import stock

def plain(s):
    i = 0
    j = len(s)
    while i < j and s[i] == " ":
        i = i + 1
    while j > i and s[j - 1] == " ":
        j = j - 1
    return s[i:j]

def pad(s, n):
    out = s
    while len(out) < n:
        out = out + " "
    return out

def lpad(s, n):
    out = s
    while len(out) < n:
        out = " " + out
    return out

def names_text(ns):
    out = ""
    for i in range(0, len(ns)):
        if i > 0 and i == len(ns) - 1:
            out = out + " and "
        elif i > 0:
            out = out + ", "
        out = out + ns[i]
    return out

def signed(n):
    if n > 0:
        return "+" + str(n)
    return str(n)

def item_text(count, code):
    return code + " " + count[code][0] + " " + str(count[code][1])

def show(before, now):
    print("  code  before                now")
    for code in all_codes(before, now):
        a = "-"
        b = "-"
        if code in before:
            a = before[code][0] + " " + str(before[code][1])
        if code in now:
            b = now[code][0] + " " + str(now[code][1])
        print("  " + code + "  " + pad(a, 22) + b)
    print("  " + str(len(before)) + " items before, " + str(len(now)) + " now.")

def all_codes(before, now):
    both = {}
    for c in before:
        both[c] = 1
    for c in now:
        both[c] = 1
    return stock.sorted_codes(both)

def differences(before, now):
    d = stock.diff(before, now)
    print("Differences, before to now:")
    if len(d["added"]) == 0:
        print("  added:   none")
    for code in d["added"]:
        print("  added:   " + item_text(now, code))
    if len(d["gone"]) == 0:
        print("  gone:    none")
    for code in d["gone"]:
        print("  gone:    " + item_text(before, code))
    for code in d["changed"]:
        delta = now[code][1] - before[code][1]
        print("  changed: " + code + " " + now[code][0] + " " + str(before[code][1]) + " -> " + str(now[code][1]) + " (" + signed(delta) + ")")
        if now[code][1] == 0:
            print("           " + code + " is still on the list, at 0: counted and empty, not gone.")
    for code in d["renamed"]:
        print("  renamed: " + code + " " + before[code][0] + " -> " + now[code][0])
    print("  the same: " + str(len(d["same"])) + " items")
    for p in stock.moved(before, now, d):
        print("Very likely one item under a new code: " + p[0] + " " + before[p[0]][0] + " is gone and " + p[1] + " " + now[p[1]][0] + " added, with the same name and quantity (" + str(now[p[1]][1]) + ").")
    b = stock.balance(before, now, d)
    print("Units: " + str(b[0]) + " before, " + str(b[1]) + " now: " + signed(b[2]) + " added, " + signed(0 - b[3]) + " gone, " + signed(b[4]) + " on the items in both.")

def one_way(before, now):
    d = stock.diff(before, now)
    found = []
    for code in d["added"]:
        found = found + [code]
    for code in d["changed"]:
        found = found + [code]
    print("Checking each item now against before finds " + list_or_none(found) + " - and never what is gone: " + list_or_none(d["gone"]) + ".")
    found2 = []
    for code in d["gone"]:
        found2 = found2 + [code]
    for code in d["changed"]:
        found2 = found2 + [code]
    print("Checking each item before against now finds " + list_or_none(found2) + " - and never what is new: " + list_or_none(d["added"]) + ".")

def list_or_none(xs):
    if len(xs) == 0:
        return "nothing"
    return names_text(xs)

def good_code(c):
    if len(c) != 4 or c[0] < "A" or c[0] > "Z":
        return False
    for k in range(1, 4):
        if c[k] < "0" or c[k] > "9":
            return False
    return True

def ask_qty():
    t = plain(input("quantity (0 to 99999, or remove)> "))
    if t == "":
        print("Cancelled.")
        return 0 - 1
    if t == "remove":
        return 0 - 2
    if len(t) == 0 or len(t) > 5:
        print("Type a whole number from 0 to 99999, or remove.")
        return 0 - 1
    for c in t:
        if c < "0" or c > "9":
            print("Type a whole number from 0 to 99999, or remove.")
            return 0 - 1
    return int(t)

def change(now):
    code = plain(input("code (a capital letter and 3 digits, such as D400)> "))
    if code == "":
        print("Cancelled.")
        return now
    if not good_code(code):
        print("Type a capital letter and 3 digits, such as D400.")
        return now
    if code in now:
        q = ask_qty()
        if q == 0 - 1:
            return now
        if q == 0 - 2:
            print(code + " " + now[code][0] + " taken off the new count.")
            return stock.remove_item(now, code)
        print(code + " " + now[code][0] + ": " + str(now[code][1]) + " -> " + str(q) + ".")
        return stock.set_item(now, code, now[code][0], q)
    name = plain(input("name (up to 20 lowercase letters and spaces)> "))
    if name == "":
        print("Cancelled.")
        return now
    if len(name) > 20:
        print("Type up to 20 lowercase letters and spaces.")
        return now
    for c in name:
        if not (c >= "a" and c <= "z" or c == " "):
            print("Type up to 20 lowercase letters and spaces.")
            return now
    q = ask_qty()
    if q == 0 - 1:
        return now
    if q == 0 - 2:
        print(code + " is not on the new count.")
        return now
    print(code + " " + name + " added to the new count, " + str(q) + ".")
    return stock.set_item(now, code, name, q)

before = stock.before_count()
now = stock.now_count()
print("== Inventory diff ==")
print("Two stock counts, last month's and this month's, compared in both directions.")
running = True
while running:
    print("")
    print("1) both counts  2) differences  3) one-way checks  4) change the new count  5) quit")
    choice = plain(input("choice> "))
    if choice == "1":
        show(before, now)
    elif choice == "2":
        differences(before, now)
    elif choice == "3":
        one_way(before, now)
    elif choice == "4":
        now = change(now)
    elif choice == "5":
        running = False
    else:
        print("Pick a number from 1 to 5.")
print("Bye.")
```

### stock.eml

```eml
# P123 inventory diff - two stock counts, each item a code, a name and a
# quantity. As in the corpus case inventory-diff-report, the difference
# has two directions: what is new now and what is gone since, and a report
# that asks only one of them misses the other. As in dict-inventory-report,
# the counts are dicts keyed by code, reported in sorted order. A quantity
# of 0 is still on the list; an item off the list is gone.
#
# A count is {code: [name, quantity]}.

def before_count():
    return {"A100": ["bolts", 120], "A101": ["nuts", 300], "A102": ["washers", 75], "B200": ["hinges", 40], "B201": ["brackets", 12], "C300": ["glue", 9], "C301": ["sealant", 5]}

def now_count():
    return {"A100": ["bolts", 120], "A101": ["nuts", 260], "A102": ["washers", 75], "B200": ["door hinges", 40], "B205": ["brackets", 12], "C300": ["glue", 0], "D400": ["tape", 30]}

def sorted_codes(count):
    # the codes in order, by insertion into a growing sorted list
    [] => out
    for code in count:
        [] => nxt
        False => placed
        for c in out:
            if not placed and code < c:
                nxt + [code] => nxt
                True => placed
            nxt + [c] => nxt
        if not placed:
            nxt + [code] => nxt
        nxt => out
    return out

def only_in(a, b):
    # the codes in a and not in b, in order
    [] => out
    for code in sorted_codes(a):
        if not (code in b):
            out + [code] => out
    return out

def diff(before, now):
    # {"added", "gone", "changed", "renamed", "same"}: lists of codes
    [] => changed
    [] => renamed
    [] => same
    for code in sorted_codes(now):
        if code in before:
            if before[code][1] != now[code][1]:
                changed + [code] => changed
            if before[code][0] != now[code][0]:
                renamed + [code] => renamed
            if before[code][1] == now[code][1] and before[code][0] == now[code][0]:
                same + [code] => same
    return {"added": only_in(now, before), "gone": only_in(before, now), "changed": changed, "renamed": renamed, "same": same}

def moved(before, now, d):
    # pairs [gone code, added code] with the same name and quantity: very
    # likely one item counted under a new code
    [] => out
    for g in d["gone"]:
        for a in d["added"]:
            if before[g][0] == now[a][0] and before[g][1] == now[a][1]:
                out + [[g, a]] => out
    return out

def units(count):
    0 => n
    for code in count:
        n + count[code][1] => n
    return n

def balance(before, now, d):
    # [units before, now, added, gone, change in the items on both]
    0 => added
    for code in d["added"]:
        added + now[code][1] => added
    0 => gone
    for code in d["gone"]:
        gone + before[code][1] => gone
    0 => change
    for code in d["changed"]:
        change + now[code][1] - before[code][1] => change
    return [units(before), units(now), added, gone, change]

def copy_count(count):
    {} => out
    for code in count:
        [count[code][0], count[code][1]] => out[code]
    return out

def set_item(count, code, name, qty):
    copy_count(count) => out
    [name, qty] => out[code]
    return out

def remove_item(count, code):
    {} => out
    for c in count:
        if c != code:
            [count[c][0], count[c][1]] => out[c]
    return out
```

Python projection of stock.eml:

```python
def before_count():
    return {"A100": ["bolts", 120], "A101": ["nuts", 300], "A102": ["washers", 75], "B200": ["hinges", 40], "B201": ["brackets", 12], "C300": ["glue", 9], "C301": ["sealant", 5]}

def now_count():
    return {"A100": ["bolts", 120], "A101": ["nuts", 260], "A102": ["washers", 75], "B200": ["door hinges", 40], "B205": ["brackets", 12], "C300": ["glue", 0], "D400": ["tape", 30]}

def sorted_codes(count):
    out = []
    for code in count:
        nxt = []
        placed = False
        for c in out:
            if not placed and code < c:
                nxt = nxt + [code]
                placed = True
            nxt = nxt + [c]
        if not placed:
            nxt = nxt + [code]
        out = nxt
    return out

def only_in(a, b):
    out = []
    for code in sorted_codes(a):
        if not code in b:
            out = out + [code]
    return out

def diff(before, now):
    changed = []
    renamed = []
    same = []
    for code in sorted_codes(now):
        if code in before:
            if before[code][1] != now[code][1]:
                changed = changed + [code]
            if before[code][0] != now[code][0]:
                renamed = renamed + [code]
            if before[code][1] == now[code][1] and before[code][0] == now[code][0]:
                same = same + [code]
    return {"added": only_in(now, before), "gone": only_in(before, now), "changed": changed, "renamed": renamed, "same": same}

def moved(before, now, d):
    out = []
    for g in d["gone"]:
        for a in d["added"]:
            if before[g][0] == now[a][0] and before[g][1] == now[a][1]:
                out = out + [[g, a]]
    return out

def units(count):
    n = 0
    for code in count:
        n = n + count[code][1]
    return n

def balance(before, now, d):
    added = 0
    for code in d["added"]:
        added = added + now[code][1]
    gone = 0
    for code in d["gone"]:
        gone = gone + before[code][1]
    change = 0
    for code in d["changed"]:
        change = change + now[code][1] - before[code][1]
    return [units(before), units(now), added, gone, change]

def copy_count(count):
    out = {}
    for code in count:
        out[code] = [count[code][0], count[code][1]]
    return out

def set_item(count, code, name, qty):
    out = copy_count(count)
    out[code] = [name, qty]
    return out

def remove_item(count, code):
    out = {}
    for c in count:
        if c != code:
            out[c] = [count[c][0], count[c][1]]
    return out
```

## README

# P123 - Inventory diff

Two stock counts, last month's and this month's, each item a code, a name
and a quantity, compared in both directions.

- `main.eml` - the menu, both counts side by side, the differences, the
  one-way checks, and changes to the new count
- `stock.eml` - the two counts, codes in order, the differences, items
  very likely moved to a new code, the units balance, and the changes

How each part works:

- As in the corpus case `inventory-diff-report`, a difference has two
  directions: what is new now, and what is gone since. Checking each item
  now against before finds the added and the changed, and never the gone;
  checking before against now never finds the added.
- Items on both counts are changed (another quantity), renamed (another
  name under the same code), or the same. A quantity of 0 is still on the
  list: counted and empty, not gone.
- The same item counted under a new code shows up as one gone and one
  added. When a gone item and an added one have the same name and
  quantity, the program says it is very likely one item.
- Units now - units before = added - gone + the change on the items in
  both: 537 - 561 = 42 - 17 - 49.
- As in the corpus case `dict-inventory-report`, the counts are dicts
  keyed by code, always reported in sorted order.

What is checked: menu choices 1 to 5; a code of a capital letter and 3
digits; a quantity from 0 to 99999, or remove for an item on the new
count; a name of up to 20 lowercase letters and spaces for a new one. An
empty answer cancels.

Sessions: `sessions/basic.in` gives:
- both counts, the differences and the one-way checks;
- B205 taken off and B201 added back, after which brackets are the same
  item again;
- glue from 0 to 4, the differences and the one-way checks.

`sessions/bad-input.in` gives:
- menu choices 0 and x;
- codes: a cancel, d400, D40 and DD400;
- quantities for D400: a cancel, x, 100000 and -3;
- a new code Z999: a cancel, Tape, a name too long, then tape with a
  cancel and remove, tape 2, and masking tape 7;
- the differences.

Built on the verified corpus cases `inventory-diff-report` (a difference
taken in both directions) and `dict-inventory-report` (counts in a dict,
reported in a fixed order).
