Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow infinite count of items #656

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open

Allow infinite count of items #656

wants to merge 26 commits into from

Conversation

xsebek
Copy link
Member

@xsebek xsebek commented Aug 31, 2022

  • use integers extended with negative and positive infinity for inventory count and numbers in-game
  • fix hashing
  • add infinity constant
    • make it the same as in JSON
    • pretty print infinities using this constant
  • add integration test with infinities in scenario
  • closes Infinite items in inventory #621

@byorgey
Copy link
Member

byorgey commented Sep 1, 2022

I don't understand why Natural makes it crash, can you elaborate?

src/Swarm/Game/Entity.hs Outdated Show resolved Hide resolved
src/Swarm/Game/Entity.hs Outdated Show resolved Hide resolved
src/Swarm/Game/Entity.hs Outdated Show resolved Hide resolved
src/Swarm/Game/Step.hs Outdated Show resolved Hide resolved
@byorgey
Copy link
Member

byorgey commented Sep 4, 2022

Random thought: what if the Swarm int type included positive and negative infinity? That would solve the problem with count having to return an arbitrary value when you have infinity of something. We'd have to consider carefully how arithmetic would work, and we'd probably have to add some new primitives like isInf. But it doesn't seem like an immediately terrible idea.

@byorgey
Copy link
Member

byorgey commented Sep 4, 2022

Let me see if I can write down a coherent explanation of what's going on with the hashing. The basic issue is with hashing inventories. We want our hashes to satisfy two main criteria:

  1. Two unequal inventories should have a high probability of having different hashes.
  2. It should be possible to update the hash of an inventory in O(1) when an entity is added or removed.

Note that inventories are considered unequal if there is at least one entity that the two inventories have different numbers of. However, in this case the "number of" an entity can include any nonnegative value but ALSO a special "Nothing" value meaning "I have never heard of this entity". We are now proposing to add yet another special value, positive infinity.

Criterion (2) above is what makes things interesting. Without that criterion everything becomes easy, in fact, we could auto-generate the hashing code for an inventory. But the auto-generated code iterates through the whole inventory and combines hashes for entities in a linearly chained way, so inserting or removing an entity means iterating through the entire inventory to recompute the hash from scratch. #294 fixed this by switching to a homomorphic hashing scheme for inventories. Instead of combining hashes by concatenating them and running the hash function again (say), we simply add them. This is less cryptographically secure but we don't care about that, and it gives us criterion (2): given the existing hash for an inventory, if we insert an entity, just add the entity's hash to the inventory hash. Also, if we add 100 copies of a given entity, we can just multiply the hash by 100 and then add that, rather than adding it 100 times.

Currently, if we have k copies of an entity in the inventory, it contributes (k+1) times the entity's hash to the inventory hash. The reason for the k+1 is to accommodate the difference between not knowing about an entity at all (in which case its hash is not added to the inventory hash at all) and knowing about it but having 0 (in which case its hash is added once). This works though since it is homomorphic with respect to adding and removing entities. If we insert one copy of an entity into an inventory, we just add its hash to the inventory hash. Say we used to have 3 copies, which means 4 times its hash was contributed to the inventory hash... so now 5 times its hash will be contributed, which corresponds to having 4 copies, just like it should.

However, positive infinity throws a wrench into things. I think we can make it work but we need to check more cases. As a concrete proposal, let h denote the hash of an entity, and suppose we use

  • 0 to represent not knowing about an entity
  • 1h to represent having infinity of the entity
  • 2h to represent having 0
  • 3h to represent having 1
  • ... (k+2)h to represent having k

So far, so good. But one problem is that this is not homomorphic with respect to adding and removing. Suppose we have infinity copies of an entity, and we insert one more. Now we should still have infinity, but that means we can't just add a copy of the hash every time we insert. We have to first check to see how many copies we have before the insert.

  • If we didn't know about the entity before, we should add 3h to the inventory hash.
  • If we had some finite number of copies, we add h to the inventory hash.
  • If we had infinite copies, we don't change the inventory hash.

Taking a union of inventories is also more complex. Honestly it seems like when unioning two inventories we might as well go ahead and recompute the hash of the result from scratch, since it is not worth the trouble of trying to update incrementally.

@xsebek
Copy link
Member Author

xsebek commented Sep 5, 2022

what if the Swarm int type included positive and negative infinity?

I am starting to like that idea. It would make it easier to use code written with the expectation that Count = Int and make division safer in the common case 1/0. There would be one funny case of 1/0 - 1/0, which is not that likely to bother anyone.

@xsebek
Copy link
Member Author

xsebek commented Sep 5, 2022

... If we had infinite copies, we don't change the inventory hash.

Well, that is annoying. Maybe we can use updateLookupWithKey to get the new value and if it is the same infinity we do not add the hash?

@byorgey
Copy link
Member

byorgey commented Sep 6, 2022

Well, that is annoying. Maybe we can use updateLookupWithKey to get the new value and if it is the same infinity we do not add the hash?

Yeah, I think something like that could work.

@xsebek
Copy link
Member Author

xsebek commented Oct 18, 2022

Rebased and changed numbers to use negative and positive infinity. 🙂

Btw. this works now in creative mode:

l <- count "Linux"; return $ l == 1/0

This approach is buggy in two ways:
- it can crash because Natural is too strict
- it breaks the fragile hashing laws for inventory

A better approach would be to either do without
a Num instance and handle every usage carefully
or add negative infinity too and use integers.

In that case we might as well add them to the number
values in game, though that increases the risk
of the infinities being added together.
But that can be handled with a safeAdd function.
@xsebek
Copy link
Member Author

xsebek commented Oct 18, 2022

Rebased again to see if the VS Code will be fixed 🙂

@xsebek xsebek marked this pull request as ready for review October 19, 2022 11:20
@xsebek xsebek requested a review from byorgey October 19, 2022 11:21
Comment on lines +1411 to +1412
E.PosInfinity -> replicate 42
E.NegInfinity -> replicate (-42)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooops, this is a problem. How should we salvage infinite inventory? 🤔

Copy link
Member Author

@xsebek xsebek Oct 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On IRC we agreed that it should take 1 tick. The idea is that an infinite item is like an item generator so we move it like it had count 1.

There is still an issue with how to do that. I think some "game state update" on the stack of the reprogrammed salvaged robot could work. An internal giveInfinite function also makes sense.

@xsebek xsebek changed the title WIP: Infinite count Allow infinite count of items Oct 21, 2022
@byorgey
Copy link
Member

byorgey commented Oct 22, 2022

Something doesn't look right:

num-printing

Copy link
Member

@byorgey byorgey left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only code I haven't yet gone over carefully is the inventory manipulation code in Game.Entity.

@@ -263,7 +263,7 @@ data CESK
Up Exn Store Cont
| -- | The machine is waiting for the game to reach a certain time
-- to resume its execution.
Waiting Integer CESK
Waiting Number CESK
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why should this be a Number? It's not useful for this to be infinity, is it? Number is used to represent counts of things, but I don't see why it should also be used to represent times.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, wait, I think I get it --- Number is the Haskell type that is used internally to represent Swarm values of type int. So since wait : int -> cmd unit, therefore Waiting has to take a Number because wait infinity is now valid Swarm code. Did I understand it right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it might be less confusing for us to rename int to number.

Besides, it will eventually be useful to wait indefinitely and get woken up by some other robot.

@@ -450,9 +452,7 @@ entityInventory = hashedLens _entityInventory (\e x -> e {_entityInventory = x})
-- Inventory
------------------------------------------------------------

-- | A convenient synonym to remind us when an 'Int' is supposed to
-- represent /how many/ of something we have.
type Count = Int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could still keep this comment but just change Int to Number.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ups, I should have kept that. 👍

hashCount :: Num p => Number -> p
hashCount c = case c of
PosInfinity -> 1
Integer a | a >= 0 -> 1 + fromIntegral a
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this mean PosInfinity and Integer 0 both turn into 1? Maybe I'm not understanding what this function is for.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I think I originally had 2 + and then mistakenly changed that. That will be a pain to adjust again. 🙁

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear, I don't yet fully understand how this is used so it might be correct.

fromElems = foldl' (flip (uncurry insertCount)) empty
fromElems = insertInventoryList empty

insertInventoryList :: Inventory -> [(Count, Entity)] -> Inventory
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a Haddock comment.

In (TRequireDevice {}) e s k -> return $ In (TConst Noop) e s k
In (TRequire {}) e s k -> return $ In (TConst Noop) e s k
In TRequireDevice {} e s k -> return $ In (TConst Noop) e s k
In TRequire {} e s k -> return $ In (TConst Noop) e s k
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did fourmolu insist on getting rid of these parens? I understand intellectually why they are not needed, but I kinda hate it. In TRequire {} e s k really looks like In is taking 5 parameters.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My HLint does not like it and I got tired of it mentioning it. I will change it to (TRequire _num _ent).

Copy link
Member

@byorgey byorgey Oct 22, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also turn off specific hlint hints in .hlint.yaml.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I think it is useful to remove extra parenthesis, but this is an edge case.

Presumably, it is different between some HLint versions because CI did not warn about it.

@@ -1627,13 +1641,13 @@ execConst c vs s k = do
missingParentInv = neededParentInv `E.difference` parentInventory
missingMap =
M.fromList
. filter ((> 0) . snd)
. filter (\(n, x) -> x > 0 && (x /= PosInfinity || countByName n parentInventory /= PosInfinity))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you walk me through the logic here? I'm not sure I follow.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be safe removing an infinite count of items from an infinite count of items is still an infinite count of items.

But here we were being smart by removing the robot inventory from required items.

So the infinity stays and is not 0.

It is a hack to keep the changes minimal, but we should eventually clean up the code by changing the data structures.
I will write my thoughts on that in a separate comment.

src/Swarm/Game/Step.hs Outdated Show resolved Hide resolved
safeExp :: Has (Throw Exn) sig m => Number -> Number -> m Number
safeExp = \case
PosInfinity -> \b -> return $ PosInfinity * signum b
NegInfinity -> \b -> return $ NegInfinity * signum b
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

infinity ^ 0 ought to be 1, not 0.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well spotted. 👍

. map (swap . second (^. entityName))
. E.elems
$ missingParentInv

-- If we're missing anything, throw an error
E.isEmpty missingParentInv
null missingMap
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did this change from checking missingParentInv to checking missingMap? Was this a bug before?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure. But if I hack in the infinity check above, I also need to change this.

The inventory version is likely smart and will ignore 0. See my future comment about the overall data structure.

src/Swarm/TUI/View.hs Outdated Show resolved Hide resolved
@xsebek
Copy link
Member Author

xsebek commented Oct 22, 2022

My comments on the inventory representation

data Inventory = Inventory
  { counts :: IntMap (Count, Entity)
  , -- ...

While updating the code to work with infinities, I came to the conclusion that this is a problem.

In the infamous words of every programmer: "It worked."

But there are issues with this representation:

  1. it allows an invalid state - count should never be negative
  2. it is abused to also work as known:: Set Entity (or IntSet if we use hash) which causes us to do strange things when counting the hash
  3. the stored entity reference is duplicating information that is known globally
  4. (new) with infinite count the logic of subtraction does not hold

Doubtful PROs of current representation

Keeping this machinery working is beyond my mental capabilities and I am not sure what are its benefits.

Distributed entity knowledge

From the look of the code, I guess that it tries to solve the future issue of robots having entities that are not known globally.
At this point, I think it might have been premature.

Global knowledge of all entities is easier to reason about. In particular, it would allow us to detect hash collisions that might go unnoticed with distributed knowledge of entities between robots.

Performance

I hope that this was never a serious factor in the design. We do not measure the performance, so this could easily be slower and less memory efficient than using different representations designed to be easier to understand.

It fit the code somewhere

At some time there was probably a place in the code where the current representation made it easier to write.

That reason sounds good to me. Is it true still? The hacks that were required for checkRequirements suggest otherwise.

KISS representation

I did not want to change the representation here originally, but updating the hashing once more sounds worse.

I propose a new representation that my simple mind could handle:

  • global GameState will have all entities in its EntityMap
  • robots will have a special field for known entities of type IntSet using entity hashes
  • global GameState will have known entities also stored as IntSet (currently it is [Text])
  • inventory will have a separate map for infinities and for normal counts:
    data Inventory = Inventory
      { finiteCounts :: IntMap Natural
      , infiniteCounts :: IntSet
      , inventoryHash :: Int
      }
  • item reaching a zero count causes it to be deleted from the map

Hashing will stay homomorphic, but it will separately hash the infinities, the known items and the item counts.

BONUS: Containers should not have the ability to know items. That does not make sense and this will make it impossible.

BONUS2: It should be simple to write hashing function for a list of entity counts, so we can construct inventory using standard Map.fromList instead of folding.

Hash function

I also have a small problem with hash returning precomputed inventoryHash for inventory, but computing the hash anew for the entity even though we store the hash.

Ideally, I would like the behaviour to be the same and use hash e instead of (e ^. entityHash).

BONUS: All hashes should have type Hash, i.e. Word. EDIT: Nvm. that type is internal.

xsebek and others added 2 commits October 22, 2022 15:32
Co-authored-by: Brent Yorgey <byorgey@gmail.com>
Co-authored-by: Brent Yorgey <byorgey@gmail.com>
@byorgey
Copy link
Member

byorgey commented Oct 22, 2022

@xsebek thanks for writing that up! I love it, and I think that will make a lot of code simpler and solve many of our issues with this PR.

@xsebek
Copy link
Member Author

xsebek commented Sep 10, 2024

@byorgey, I would like to revive this next, but I might need help. 😄

As far as I know, the points in my final summary are still valid. I will see if they can be split into smaller issues.

@byorgey
Copy link
Member

byorgey commented Sep 10, 2024

I would like to revive this next, but I might need help. 😄

Yes, please do! 🎉 According to my current thinking this is a prerequisite for #231 . I will try to read through this again soon and see how I might be able to help.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Infinite items in inventory
2 participants