├── .gitignore ├── Deskfile ├── LICENSE.md ├── README.md ├── dev_requirements.txt ├── patchwork ├── __init__.py ├── actions.py ├── context.py ├── datastore.py ├── hypertext.py ├── interface.py ├── main.py ├── scheduling.py └── text_manipulation.py ├── requirements.txt ├── sorted_list.gif └── tests ├── __init__.py ├── test_basic.py └── test_laziness.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # pyre 107 | .pyre/ 108 | .pyre_configuration 109 | -------------------------------------------------------------------------------- /Deskfile: -------------------------------------------------------------------------------- 1 | source env/bin/activate 2 | export MYPYPATH=$VIRTUAL_ENV/lib/python3.6/site-packages 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ought Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Patchwork 2 | 3 | ![](sorted_list.gif) 4 | 5 | This repository contains an implementation of an 6 | [HCH](https://ai-alignment.com/humans-consulting-hch-f893f6051455) test bed. 7 | It is intended to serve as a model for a multi-user web app, and thus explicitly 8 | represents and manages all state in a data store. 9 | 10 | In the terms used by Ought's [taxonomy of approaches to capability 11 | amplification](https://ought.org/projects/factored-cognition/taxonomy), 12 | this program implements question-answering with: 13 | 14 | * Recursion 15 | * Pointers 16 | * A weak form of reflection, in which trees of workspaces can be passed around and 17 | inspected, but actions are not reifiable and not always inferrable. Specifically, 18 | pointer unlocking actions cannot be recovered. 19 | * Caching (memoization) 20 | * Lazy evaluation 21 | 22 | The general idea here is that this is a system for breaking problems down into 23 | sub-problems. Given a starting problem, a human (H) takes actions that can either 24 | create sub-problems (contexts), unlock data (pointers), track some internal or 25 | strategic state (scratchpads), or solve the problem (reply to the question). 26 | 27 | We make the assumption that H is a pure function from contexts to actions. This 28 | allows us to perform automation to avoid making unnecessary calls to H, as 29 | seen in the "is this list sorted" demo gif above. 30 | 31 | ## Setup 32 | 33 | In order to use this package, you'll need at least Python 3.6 and parsy 1.2.0. 34 | 35 | You can get parsy by running `pip install -r requirements.txt`. 36 | 37 | ## Usage 38 | 39 | To begin, run 40 | 41 | ```bash 42 | python -m patchwork.main [optional_database_file] 43 | ``` 44 | 45 | The app can be used to answer simple questions. When the app starts, the user 46 | will be presented with a prompt to enter a "root-level question". From here on, 47 | the user will be presented with a sequence of “contexts”. 48 | 49 | ### Interpreting a context 50 | 51 | At any given moment during the program's execution, you're looking at a context. 52 | A context can display four fields: 53 | 54 | 1. A pointer to a predecessor workspace 55 | 2. A pointer to a question to be answered (unlocked) 56 | 3. A pointer to a scratchpad (unlocked) where intermediate work can be cached 57 | 4. A list of pointers to subquestions. The subquestions themselves are unlocked, 58 | but the answers and workspaces used to compute those answers are not. 59 | 60 | #### Hypertext in contexts 61 | 62 | Contexts contain pointers, which can be thought of as links to pages in a web of 63 | hypertext. They are abstract references to data. A pointer can either 64 | be "locked" or "unlocked". Locked pointers appear as `$`, where `` is either 65 | a number, or a special identifier followed by a number. So `$12` is a locked pointer, 66 | `$q1` is the locked pointer to your first subquestion, `$a1` is the locked pointer to 67 | the first subquestion's answer, and `$w1` is the locked pointer to the workspace that 68 | generated the first subquestion's answer. 69 | 70 | Unlocked pointers appear inside square brackets, with the pointer id prepended to 71 | the content of the data: `[$1: Hello, world]` represents an unlocked pointer to 72 | hypertext containing the string `Hello, world`. Hypertext can either represent 73 | a workspace (as in the `$w1` example), or can be "raw", representing some 74 | unstructured text. 75 | 76 | ### Taking actions 77 | 78 | Four actions can be taken in any context: 79 | 1. `ask `: Ask a subquestion 80 | 2. `scratch `: replace the scratchpad contents with 81 | ``. 82 | 3. `unlock `: Make a successor context that has 83 | the pointer `` visible. 84 | 4. `reply `: Answer the question with `` 85 | 86 | #### Hypertext syntax 87 | 88 | The `ask`, `scratch`, and `reply` commands all accept hypertext as an 89 | argument. The hypertext you specify here is similar to the hypertext 90 | that appears throughout the context, but is slightly different. 91 | 92 | In particular, expanded pointers in your hypertext should not have 93 | identifier prefixes. For example, you might 94 | `ask is the list [[a] [[b] [[c] [[d] []]]]] sorted?`. Otherwise, the hypertext 95 | you construct is syntactically identical to the hypertext in a context. 96 | 97 | The `unlock` action accepts a pointer exactly as it appears in the context: 98 | For example, `unlock $w1`. 99 | 100 | > USAGE NOTE: This system is _lazy_. This means that unlocking a pointer automatically 101 | puts the successor context on hold until at least the unlocked result is ready. However, 102 | locked pointers can be _passed around_ arbitrarily before being unlocked. So if 103 | I want to ask three questions in sequence, I can pass the answer of the first 104 | to the second, and the answer of the second to the third, without unlocking anything, 105 | and without being put on hold. When I unlock the third answer, my successor will not 106 | wake up until that answer is actually available. 107 | 108 | ## Implementation details 109 | 110 | The system is implemented using the following concepts. It is intended to 111 | serve as a model for a multi-user web app version, in which the database 112 | stores the contents of the datastore and the scheduler. 113 | 114 | ### Content-addressed datastore 115 | 116 | The Datastore and Address classes are the mechanism used for lazily storing 117 | references to deduplicated data. Addresses are basically unique identifiers, 118 | while the datastore is used to keep track of both (a) what data exists, and 119 | (b) what data is pending (and who is waiting on it). 120 | 121 | When duplicate data is inserted, the address of the original data is returned. 122 | If a promise would be fulfilled with duplicate data, the promise is added to 123 | a list of aliases, such that anything that tries to refer to that promise will 124 | be redirected to the deduplicated data (even though their address does not match 125 | the canonical address of that data). 126 | 127 | ### Hypertext 128 | 129 | The datastore can be seen as an analogue for an HTTP server, and its contents 130 | can be seen as analogues for HTML pages with references to other pages on that 131 | server. Hypertext equality is based on the string that it gets converted to 132 | when printed (with any addresses "canonicalized", i.e. replaced by the canonical 133 | value of their corresponding canonical address). 134 | 135 | #### Workspaces 136 | 137 | A workspace is a structured hypertext object that contains pointers to the 138 | data that's visible from a context: an optional predecessor, a question, 139 | a scratchpad, and a list of subquestions with their answers and final 140 | workspaces. 141 | 142 | ### Context 143 | 144 | A context is a view of a workspace that also contains a set of pointers that 145 | are unlocked in that context. The pointers that are unlocked are replaced 146 | in the textual representation by text in square brackets. By default, the 147 | workspace's question, scratchpad, and subquestions are unlocked. 148 | 149 | ### Action 150 | 151 | Action objects represent the actions that can be taken by the user: 152 | There is one class for each action that can be taken. 153 | 154 | Actions are not taken immediately on creation; they are executed by the 155 | scheduler when the scheduler sees fit, making updates to the datastore 156 | and producing a new set of contexts. 157 | 158 | ### Scheduler 159 | 160 | The scheduler is the part of the system that decides when to execute 161 | actions, and which context to show to which user and when. It also 162 | manages automation, by remembering contexts and taking its own 163 | actions under the assumption that the user is a pure function from 164 | context to action. 165 | 166 | ## Future Work 167 | 168 | This system is not entirely ready for prime-time. If you play with it for long, 169 | you are likely to uncover bugs. Furthermore, the abstractions used here are probably 170 | not powerful enough to build a complete HCH system. 171 | 172 | ### "True" Reflection 173 | 174 | The current system allows trees of workspaces to be passed around, and each workspace 175 | includes a link to its predecessor, so some actions can be inferred. However, for all 176 | actions to be inferred (including pointer unlocking), we would probably need to reify 177 | the history of contexts in a way that's accessible through a pointer. This might in 178 | turn imply that contexts and actions should be instances of hypertext. 179 | 180 | ### Budgets 181 | 182 | The current system does not support budgets. This naively results in cases where 183 | infinite automation loops are possible. While we've avoided those here by implementing 184 | an explicit check against them, strictly decreasing budgets would eliminate the 185 | need for this complexity. 186 | 187 | Once budgets are in place, we'll need to consider the interaction between budgets and 188 | automation. Since budgets are part of the workspace state, differences in budgets 189 | result in cache-based automation treating the corresponding contexts as different. 190 | This reduces the number of cache hits substantially. This could be addressed 191 | by: 192 | 193 | 1. Only showing budgets rounded to the nearest power of 10. (This is what Paul did in some implementations.) 194 | 2. Hiding the budget behind a pointer so that users can ask questions about it (e.g., "What is the nearest power of 10 for budget #b") 195 | 3. Using a more general prediction scheme instead of cache-based automation, so that some contexts are treated as sufficiently similar for automation to apply even if the budgets differ. 196 | 197 | We should also reconsider using VOI-based budgets. 198 | 199 | ### Exceptional Cases and Speculative Execution 200 | 201 | The current design allows the user to pass around answer pointers that have not been 202 | completed yet. This is normally fine, but imagine that the system is somehow unable 203 | to complete the request - maybe the question was malformed, or maybe the user didn't 204 | have enough budget. There needs to be a way to indicate that an answer has "failed" - 205 | otherwise, you'll end up with "What is 7 * [I can't answer that question]?" 206 | 207 | One possibility would be to have each subquestion generate two possible resulting 208 | contexts: a "success" and a "failure". The system could then instantiate only the more 209 | likely of the two successors; doubling back to instantiate the other (and invalidate 210 | work that depended on it) if it turns out to have been wrong. This is similar to how 211 | branch prediction works in CPUs. 212 | 213 | A generalization of this idea is to do speculative execution on the text of a 214 | message. I.e., if the answer is commonly "The answer is #1", maybe we can just 215 | predict that the answer has this shape (without filling in #1) and if we do the 216 | computation and it turns out to be different, we can double back to go with the 217 | actual response. (This may be easier if we have edits or otherwise strong reuse 218 | of computation.) 219 | 220 | In addition to doing speculative execution on the answer from a sub-question, we 221 | can also do speculative execution for pointer expansions. If we have a lazy pointer 222 | #1, we can predict what its value will be and go with that, later updating it if 223 | we do the computation and it turns out different. 224 | 225 | It would be good to better understand how speculative execution and full 226 | question-answer prediction (as in distillation) relate. Can these be built using 227 | the same shared technology 228 | 229 | ### Multiple Sessions and Users 230 | 231 | While the basic idea of user sessions is visible in the code as it stands today, 232 | this is hacky and would probably not stand up to implementing a multi-user 233 | frontend immediately. There are several questions that would need to be answered 234 | in order to successfully manage multiple users; for example, what should happen if 235 | a root question is already being dispatched by another user? 236 | 237 | ### Edits 238 | 239 | Suppose you asked a question which resulted in a big tree of sub-computations. You 240 | (or rather, your successor) then realized that there was a mistake and that you 241 | should have asked the question differently. In that case, reflection might help 242 | a bit - you can ask "What is the answer to $q2 given that we previously asked 243 | about $q1 which resulted in computation $c1?". The agents answering $q2 can then 244 | unroll the computation $c1 and reuse some of the work. However, there is probably 245 | some work to be done to make this as convenient as 246 | [incremental improvement through edits](https://ought.org/projects/factored-cognition/taxonomy#persistence). 247 | 248 | Re-asking a question with increased budget and re-asking with a pointer to a slightly 249 | different object are important special cases. 250 | 251 | If it is not the case that edits can be simulated well in a system where questions 252 | and pointer values are immutable, we should reconsider the pros and cons of allowing 253 | actual edits. 254 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | mypy==0.600 2 | -------------------------------------------------------------------------------- /patchwork/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oughtinc/patchwork/a7acbc5489f098dac64d90b5a8efc583b1857750/patchwork/__init__.py -------------------------------------------------------------------------------- /patchwork/actions.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Tuple 2 | 3 | from .context import Context, DryContext 4 | from .datastore import Datastore 5 | from .hypertext import Workspace 6 | from .text_manipulation import create_raw_hypertext, insert_raw_hypertext 7 | 8 | class Action(object): 9 | def execute( 10 | self, 11 | db: Datastore, 12 | context: Context, 13 | ) -> Tuple[Optional[Context], List[Context]]: 14 | # Successor context should be first if it exists. 15 | raise NotImplementedError("Action is pure virtual") 16 | 17 | 18 | # Predictability here means "the user can predict what the successor looks like based only 19 | # on the current workspace and the action taken." It's not very clear whether Reply should 20 | # count or not, since it has no successor. 21 | class PredictableAction(Action): 22 | pass 23 | 24 | 25 | class UnpredictableAction(Action): 26 | pass 27 | 28 | 29 | class Scratch(PredictableAction): 30 | def __init__(self, scratch_text: str) -> None: 31 | self.scratch_text = scratch_text 32 | 33 | def execute( 34 | self, 35 | db: Datastore, 36 | context: Context, 37 | ) -> Tuple[Optional[Context], List[Context]]: 38 | 39 | new_scratchpad_link = insert_raw_hypertext( 40 | self.scratch_text, 41 | db, 42 | context.name_pointers_for_workspace(context.workspace_link, db)) 43 | 44 | current_workspace = db.dereference(context.workspace_link) 45 | 46 | successor_workspace = Workspace( 47 | current_workspace.question_link, 48 | current_workspace.answer_promise, 49 | current_workspace.final_workspace_promise, 50 | new_scratchpad_link, 51 | current_workspace.subquestions, 52 | ) 53 | 54 | successor_workspace_link = db.insert(successor_workspace) 55 | 56 | new_unlocked_locations = set(context.unlocked_locations_from_workspace( 57 | context.workspace_link, 58 | db)) 59 | new_unlocked_locations.remove(context.workspace_link) 60 | new_unlocked_locations.add(successor_workspace_link) 61 | new_unlocked_locations.add(new_scratchpad_link) 62 | 63 | return ( 64 | Context( 65 | successor_workspace_link, 66 | db, 67 | unlocked_locations=new_unlocked_locations, 68 | parent=context), 69 | [] 70 | ) 71 | 72 | 73 | class AskSubquestion(PredictableAction): 74 | def __init__(self, question_text: str) -> None: 75 | self.question_text = question_text 76 | 77 | def execute( 78 | self, 79 | db: Datastore, 80 | context: Context, 81 | ) -> Tuple[Optional[Context], List[Context]]: 82 | 83 | subquestion_link = insert_raw_hypertext( 84 | self.question_text, 85 | db, 86 | context.name_pointers_for_workspace(context.workspace_link, db)) 87 | 88 | answer_link = db.make_promise() 89 | final_sub_workspace_link = db.make_promise() 90 | 91 | scratchpad_link = insert_raw_hypertext("", db, {}) 92 | sub_workspace = Workspace( 93 | subquestion_link, 94 | answer_link, 95 | final_sub_workspace_link, 96 | scratchpad_link, 97 | [], 98 | ) 99 | current_workspace = db.dereference(context.workspace_link) 100 | 101 | sub_workspace_link = db.insert(sub_workspace) 102 | sub_workspace = db.dereference(sub_workspace_link) # in case our copy was actually clobbered. 103 | 104 | new_subquestions = (current_workspace.subquestions + 105 | [(subquestion_link, sub_workspace.answer_promise, sub_workspace.final_workspace_promise)]) 106 | successor_workspace = Workspace( 107 | current_workspace.question_link, 108 | current_workspace.answer_promise, 109 | current_workspace.final_workspace_promise, 110 | current_workspace.scratchpad_link, 111 | new_subquestions, 112 | ) 113 | 114 | successor_workspace_link = db.insert(successor_workspace) 115 | 116 | new_unlocked_locations = set(context.unlocked_locations_from_workspace( 117 | context.workspace_link, db)) 118 | new_unlocked_locations.remove(context.workspace_link) 119 | new_unlocked_locations.add(subquestion_link) 120 | new_unlocked_locations.add(successor_workspace_link) 121 | 122 | return ( 123 | Context( 124 | successor_workspace_link, 125 | db, 126 | unlocked_locations=new_unlocked_locations, 127 | parent=context), 128 | [Context(sub_workspace_link, db, parent=context)]) 129 | 130 | 131 | class Reply(UnpredictableAction): 132 | def __init__(self, reply_text: str) -> None: 133 | self.reply_text = reply_text 134 | 135 | def execute( 136 | self, 137 | db: Datastore, 138 | context: Context, 139 | ) -> Tuple[Optional[Context], List[Context]]: 140 | 141 | current_workspace = db.dereference(context.workspace_link) 142 | 143 | reply_hypertext = create_raw_hypertext( 144 | self.reply_text, 145 | db, 146 | context.name_pointers_for_workspace(context.workspace_link, db)) 147 | 148 | # final_workspace_promise and answer_promise aren't in 149 | # workspace.links(), so this is fine (doesn't create 150 | # link cycles). 151 | if not db.is_fulfilled(current_workspace.answer_promise): 152 | answer_successors = db.resolve_promise(current_workspace.answer_promise, reply_hypertext) 153 | else: 154 | answer_successors = [] 155 | 156 | if not db.is_fulfilled(current_workspace.final_workspace_promise): 157 | workspace_successors = db.resolve_promise( 158 | current_workspace.final_workspace_promise, 159 | current_workspace) 160 | else: 161 | workspace_successors = [] 162 | 163 | all_successors = [Context.from_dry(dry_context, db) 164 | for dry_context in answer_successors + workspace_successors] 165 | 166 | return (None, all_successors) 167 | 168 | 169 | class Unlock(Action): 170 | def __init__(self, unlock_text: str) -> None: 171 | self.unlock_text = unlock_text 172 | 173 | def execute( 174 | self, 175 | db: Datastore, 176 | context: Context, 177 | ) -> Tuple[Optional[Context], List[Context]]: 178 | 179 | try: 180 | pointer_address = context.name_pointers_for_workspace( 181 | context.workspace_link, 182 | db 183 | )[self.unlock_text] 184 | except KeyError: 185 | raise ValueError("{} is not visible in this context".format(self.unlock_text)) 186 | 187 | new_unlocked_locations = set(context.unlocked_locations_from_workspace( 188 | context.workspace_link, db)) 189 | 190 | if pointer_address in new_unlocked_locations: 191 | raise ValueError("{} is already unlocked.".format(self.unlock_text)) 192 | 193 | new_unlocked_locations.add(pointer_address) 194 | 195 | dry_successor_context = DryContext(context.workspace_link, 196 | new_unlocked_locations, context) 197 | 198 | if db.is_fulfilled(pointer_address): 199 | return (None, [Context.from_dry(dry_successor_context, db)]) 200 | 201 | db.register_promisee(pointer_address, dry_successor_context) 202 | return (None, []) 203 | 204 | -------------------------------------------------------------------------------- /patchwork/context.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, deque 2 | from textwrap import indent 3 | from typing import DefaultDict, Dict, Deque, Generator, List, Optional, Set, Tuple 4 | 5 | import attr 6 | 7 | from .datastore import Address, Datastore 8 | from .hypertext import Workspace, visit_unlocked_region 9 | from .text_manipulation import make_link_texts 10 | 11 | 12 | @attr.s(frozen=True) 13 | class DryContext(object): 14 | """Stores the arguments for reconstituting a Context in the future.""" 15 | workspace_link = attr.ib(type=Address) 16 | unlocked_locations = attr.ib(type=Optional[Set[Address]]) 17 | parent = attr.ib(type=Optional["Context"]) 18 | 19 | 20 | def _can_advance_promise(db: Datastore, wsaddr: Address, promise: Address) \ 21 | -> bool: 22 | """See :py:meth:`Context.can_advance_promise`.""" 23 | ws_promises = db.dereference(wsaddr).promises 24 | 25 | if promise in ws_promises: 26 | return True 27 | 28 | promisee_wsaddrs = (dry_context.workspace_link 29 | for p in ws_promises 30 | for dry_context in db.get_promisees(p)) 31 | return any(_can_advance_promise(db, pwsa, promise) 32 | for pwsa in promisee_wsaddrs) 33 | 34 | 35 | class Context(object): 36 | def __init__( 37 | self, 38 | workspace_link: Address, 39 | db: Datastore, 40 | unlocked_locations: Optional[Set[Address]]=None, 41 | parent: Optional["Context"]=None, 42 | ) -> None: 43 | 44 | # Unlocked locations should be in terms of the passed in workspace_link. 45 | 46 | self.workspace_link = workspace_link 47 | workspace = db.dereference(workspace_link) 48 | if unlocked_locations is not None: 49 | self.unlocked_locations = unlocked_locations 50 | self.unlocked_locations.add(self.workspace_link) 51 | else: 52 | # All of the things that are visible in a context with no explicit unlocks. 53 | self.unlocked_locations = set( 54 | [workspace_link, workspace.question_link, workspace.scratchpad_link] + 55 | [q for q, a, w in workspace.subquestions] + 56 | ([workspace.predecessor_link] if workspace.predecessor_link else [])) 57 | 58 | self.pointer_names, self.name_pointers = self._name_pointers(self.workspace_link, db) 59 | self.display = self.to_str(db) 60 | self.parent = parent 61 | 62 | def to_dry(self) -> DryContext: 63 | return DryContext(self.workspace_link, self.unlocked_locations, self.parent) 64 | 65 | @classmethod 66 | def from_dry(cls, dry_context: DryContext, db: Datastore) -> "Context": 67 | return cls(dry_context.workspace_link, db, 68 | dry_context.unlocked_locations, dry_context.parent) 69 | 70 | def _name_pointers( 71 | self, 72 | workspace_link: Address, 73 | db: Datastore, 74 | ) -> Tuple[Dict[Address, str], Dict[str, Address]]: 75 | pointers: Dict[Address, str] = {} 76 | backward_pointers: Dict[str, Address] = {} 77 | 78 | def assign(link, string): 79 | pointers[link] = string 80 | backward_pointers[string] = link 81 | 82 | workspace_root = db.dereference(workspace_link) 83 | for i, subquestion in reversed(list(enumerate(workspace_root.subquestions, start=1))): 84 | q, a, w = subquestion 85 | assign(q, "$q{}".format(i)) 86 | assign(a, "$a{}".format(i)) 87 | assign(w, "$w{}".format(i)) 88 | 89 | count = 0 90 | for your_link in visit_unlocked_region(self.workspace_link, workspace_link, db, self.unlocked_locations): 91 | your_page = db.dereference(your_link) 92 | for visible_link in your_page.links(): 93 | if visible_link not in pointers: 94 | count += 1 95 | assign(visible_link, "${}".format(count)) 96 | 97 | return pointers, backward_pointers 98 | 99 | def unlocked_locations_from_workspace( 100 | self, 101 | workspace_link: Address, 102 | db: Datastore, 103 | ) -> Set[Address]: 104 | result = set(visit_unlocked_region(self.workspace_link, workspace_link, db, self.unlocked_locations)) 105 | return result 106 | 107 | def name_pointers_for_workspace( 108 | self, 109 | workspace_link: Address, 110 | db: Datastore 111 | ) -> Dict[str, Address]: 112 | return self._name_pointers(workspace_link, db)[1] 113 | 114 | def to_str(self, db: Datastore) -> str: 115 | CONTEXT_FMT = "{predecessor}Question: {question}\nScratchpad: {scratchpad}\nSubquestions:\n{subquestions}\n" 116 | 117 | link_texts = make_link_texts(self.workspace_link, db, self.unlocked_locations, self.pointer_names) 118 | 119 | subquestion_builder = [] 120 | workspace: Workspace = db.dereference(self.workspace_link) 121 | for i, subquestion in enumerate(workspace.subquestions, start=1): 122 | q, a, w = subquestion 123 | q_text = link_texts[q] 124 | a_text = link_texts[a] 125 | w_text = link_texts[w] 126 | subquestion_builder.append("{}.\n{}\n{}\n{}".format(i, indent(q_text, " "), indent(a_text, " "), indent(w_text, " "))) 127 | subquestions = "\n".join(subquestion_builder) 128 | 129 | if workspace.predecessor_link is None: 130 | predecessor = "" 131 | else: 132 | predecessor = "Predecessor: {}\n".format(link_texts[workspace.predecessor_link]) 133 | 134 | return CONTEXT_FMT.format( 135 | predecessor=predecessor, 136 | question=link_texts[workspace.question_link], 137 | scratchpad=link_texts[workspace.scratchpad_link], 138 | subquestions=subquestions) 139 | 140 | def is_own_ancestor(self, db: Datastore) -> bool: 141 | initial_workspace = db.canonicalize(self.workspace_link) 142 | context: Optional[Context] = self.parent 143 | while context is not None: 144 | if context == self and db.canonicalize(context.workspace_link) == initial_workspace: 145 | return True 146 | context = context.parent 147 | return False 148 | 149 | # Note: The definition is mutually recursive, but we can implement it 150 | # with simple recursion, because we can obtain workspaces from the 151 | # datastore without constructing contexts. 152 | def can_advance_promise(self, db: Datastore, promise: Address) -> bool: 153 | """Determine if ``self`` can advance ``promise``. 154 | 155 | A context c can advance a promise p iff its workspace can advance p. 156 | 157 | A workspace w can advance a promise p iff 158 | - p is one of w's promises P(w) or 159 | - one of the promisees of the promises P(w) can advance P. 160 | The promisees of P(w) are contexts. 161 | """ 162 | return _can_advance_promise(db, self.workspace_link, promise) 163 | 164 | 165 | def __str__(self) -> str: 166 | return self.display 167 | 168 | def __hash__(self) -> int: 169 | return hash(str(self)) 170 | 171 | def __eq__(self, other: object) -> bool: 172 | if not isinstance(other, Context): 173 | return NotImplemented 174 | return self.workspace_link == other.workspace_link \ 175 | and self.unlocked_locations == other.unlocked_locations \ 176 | and self.parent == other.parent 177 | 178 | 179 | -------------------------------------------------------------------------------- /patchwork/datastore.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from collections import defaultdict 4 | 5 | from typing import Any, DefaultDict, Dict, List, Set 6 | 7 | 8 | class Address(object): 9 | def __init__(self) -> None: 10 | self.location = uuid.uuid1() 11 | 12 | def __hash__(self) -> int: 13 | return hash(self.location) 14 | 15 | def __eq__(self, other: object) -> bool: 16 | if not isinstance(other, Address): 17 | return False 18 | return self.location == other.location 19 | 20 | def __str__(self) -> str: 21 | return repr(self) 22 | 23 | def __repr__(self) -> str: 24 | return "Address({})".format(self.location) 25 | 26 | 27 | class Datastore(object): 28 | def __init__(self) -> None: 29 | self.content: Dict[Address, Any] = {} # Map from canonical address to content 30 | self.canonical_addresses: Dict[Any, Address] = {} # Map from content to canonical address 31 | self.promises: Dict[Address, List[Any]] = {} # Map from alias to list of promisees 32 | self.aliases: Dict[Address, Address] = {} # Map from alias to canonical address 33 | 34 | def dereference(self, address: Address) -> Any: 35 | return self.content[self.canonicalize(address)] 36 | 37 | def canonicalize(self, address: Address) -> Address: 38 | if address in self.aliases: 39 | return self.aliases[address] 40 | elif address in self.content or address in self.promises: 41 | return address 42 | else: 43 | raise KeyError("Don't have that address") 44 | 45 | def make_promise(self) -> Address: 46 | address = Address() 47 | self.promises[address] = [] 48 | return address 49 | 50 | def register_promisee(self, address: Address, promisee: Any) -> None: 51 | self.promises[address].append(promisee) 52 | 53 | def get_promisees(self, address: Address) -> List[Any]: 54 | return self.promises[address] 55 | 56 | def resolve_promise(self, address: Address, content: Any) -> List[Any]: 57 | assert address in self.promises, "{} not in promises".format(address) 58 | if content in self.canonical_addresses: 59 | self.aliases[address] = self.canonical_addresses[content] 60 | else: 61 | self.content[address] = content 62 | self.canonical_addresses[content] = address 63 | promisees = self.promises[address] 64 | del self.promises[address] 65 | return promisees 66 | 67 | def insert(self, content: Any) -> Address: 68 | if content in self.canonical_addresses: 69 | return self.canonical_addresses[content] 70 | 71 | address = self.make_promise() 72 | self.resolve_promise(address, content) 73 | return address 74 | 75 | def is_fulfilled(self, address: Address) -> bool: 76 | address = self.canonicalize(address) 77 | return address in self.content 78 | 79 | 80 | class TransactionAccumulator(Datastore): 81 | # A way of performing ACID-ish transactions against the Datastore 82 | def __init__(self, db: Datastore) -> None: 83 | self.db = db 84 | # Promises that were made and not fulfilled in this transaction 85 | self.new_promises: Dict[Address, List[Any]] = {} 86 | 87 | # Former promises that have been resolved by this transaction 88 | self.resolved_promises: Set[Address] = set() 89 | 90 | # Registered promisees on existing promises in this transaction 91 | self.additional_promisees: DefaultDict[Address, List[Any]] = defaultdict(list) 92 | 93 | # Content that is new and complete in this transaction 94 | self.new_content: Dict[Address, Any] = {} 95 | 96 | # inverse map of new_content 97 | self.new_canonical_addresses: Dict[Any, Address] = {} 98 | 99 | # aliases that were created in this transaction 100 | self.new_aliases: Dict[Address, Address] = {} 101 | 102 | def dereference(self, address: Address) -> Any: 103 | address = self.canonicalize(address) 104 | if address in self.new_content: 105 | return self.new_content[address] 106 | else: 107 | return self.db.content[address] 108 | 109 | def canonicalize(self, address: Address) -> Address: 110 | if address in self.new_aliases: 111 | return self.new_aliases[address] 112 | elif address in self.db.aliases: 113 | return self.db.aliases[address] 114 | elif address in self.new_content or address in self.new_promises: 115 | return address 116 | elif address in self.db.content or address in self.db.promises: 117 | return address 118 | else: 119 | raise KeyError("Don't have that address") 120 | 121 | def make_promise(self) -> Address: 122 | address = Address() 123 | self.new_promises[address] = [] 124 | return address 125 | 126 | def register_promisee(self, address: Address, promisee: Any) -> None: 127 | if address in self.resolved_promises: 128 | raise ValueError("Promise already resolved") 129 | if address in self.new_promises: 130 | self.new_promises[address].append(promisee) 131 | elif address in self.additional_promisees: 132 | self.additional_promisees[address].append(promisee) 133 | elif address in self.db.promises: 134 | self.additional_promisees[address] = [promisee] 135 | else: 136 | raise ValueError("address not a promise") 137 | 138 | def get_promisees(self, address: Address) -> List[Any]: 139 | if address in self.resolved_promises: 140 | return [] 141 | elif address in self.db.promises: 142 | return self.db.promises[address] + \ 143 | self.additional_promisees.get(address, []) 144 | elif address in self.new_promises: 145 | return self.new_promises[address] 146 | else: 147 | raise KeyError("Promise {} is not registered in the datastore." 148 | .format(address)) 149 | 150 | def resolve_promise(self, address: Address, content: Any) -> List[Any]: 151 | assert address in self.new_promises or address in self.db.promises, "{} not in promises".format(address) 152 | if content in self.db.canonical_addresses: 153 | self.new_aliases[address] = self.db.canonical_addresses[content] 154 | elif content in self.new_canonical_addresses: 155 | self.new_aliases[address] = self.new_canonical_addresses[content] 156 | else: 157 | self.new_content[address] = content 158 | self.new_canonical_addresses[content] = address 159 | 160 | if address in self.db.promises: 161 | promisees = self.db.promises[address] 162 | promisees.extend(self.additional_promisees.get(address, [])) 163 | self.resolved_promises.add(address) 164 | else: 165 | promisees = self.new_promises[address] 166 | del self.new_promises[address] 167 | return promisees 168 | 169 | def insert(self, content: Any) -> Address: 170 | if content in self.new_canonical_addresses: 171 | return self.new_canonical_addresses[content] 172 | if content in self.db.canonical_addresses: 173 | return self.db.canonical_addresses[content] 174 | 175 | address = self.make_promise() 176 | self.resolve_promise(address, content) 177 | return address 178 | 179 | def is_fulfilled(self, address: Address) -> bool: 180 | address = self.canonicalize(address) 181 | return address in self.new_content or address in self.db.content 182 | 183 | def commit(self) -> None: 184 | self.db.promises.update(self.new_promises) 185 | for a, l in self.additional_promisees.items(): 186 | self.db.promises[a].extend(l) 187 | self.db.content.update(self.new_content) 188 | self.db.canonical_addresses.update(self.new_canonical_addresses) 189 | self.db.aliases.update(self.new_aliases) 190 | for a in self.resolved_promises: 191 | del self.db.promises[a] 192 | -------------------------------------------------------------------------------- /patchwork/hypertext.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from textwrap import indent 3 | from typing import Dict, Generator, List, Optional, Set, Tuple, Union 4 | 5 | from .datastore import Address, Datastore 6 | 7 | HypertextFragment = Union[Address, str] 8 | Subquestion = Tuple[Address, Address, Address] # question, answer, final_workspace 9 | 10 | def visit_unlocked_region( 11 | template_link: Address, 12 | workspace_link: Address, 13 | db: Datastore, 14 | unlocked_locations: Optional[Set[Address]], 15 | ) -> Generator[Address, None, None]: 16 | frontier = deque([(template_link, workspace_link)]) 17 | seen = set(frontier) 18 | while len(frontier) > 0: 19 | my_link, your_link = frontier.popleft() 20 | if unlocked_locations is None or my_link in unlocked_locations: 21 | yield your_link 22 | my_page = db.dereference(my_link) 23 | your_page = db.dereference(your_link) 24 | for next_links in zip(my_page.links(), your_page.links()): 25 | if next_links not in seen: 26 | frontier.append(next_links) 27 | seen.add(next_links) 28 | 29 | 30 | class Hypertext(object): 31 | def links(self) -> List[Address]: 32 | raise NotImplementedError("Hypertext is a pure virtual class") 33 | 34 | def to_str(self, display_map: Optional[Dict[Address, str]]=None) -> str: 35 | raise NotImplementedError("Hypertext is a pure virtual class") 36 | 37 | def __str__(self) -> str: 38 | return self.to_str() 39 | 40 | def __eq__(self, other: object): 41 | if not isinstance(other, Hypertext): 42 | return False 43 | return str(self) == str(other) 44 | 45 | def __hash__(self): 46 | return hash(str(self)) 47 | 48 | 49 | class RawHypertext(Hypertext): 50 | def __init__(self, chunks: List[HypertextFragment]) -> None: 51 | self.chunks = chunks 52 | 53 | def links(self) -> List[Address]: 54 | result = [] 55 | seen: Set[Address] = set() 56 | for chunk in self.chunks: 57 | if isinstance(chunk, Address) and chunk not in seen: 58 | seen.add(chunk) 59 | result.append(chunk) 60 | return result 61 | 62 | def to_str(self, display_map: Optional[Dict[Address, str]]=None) -> str: 63 | builder = [] 64 | for chunk in self.chunks: 65 | if isinstance(chunk, str): 66 | builder.append(chunk) 67 | elif display_map is None: 68 | builder.append(str(chunk)) 69 | else: 70 | builder.append(display_map[chunk]) 71 | return ''.join(builder) 72 | 73 | 74 | class Workspace(Hypertext): 75 | def __init__( 76 | self, 77 | question_link: Address, 78 | answer_promise: Address, 79 | final_workspace_promise: Address, 80 | scratchpad_link: Address, 81 | subquestions: List[Subquestion], 82 | predecessor_link: Optional[Address]=None, 83 | ) -> None: 84 | self.question_link = question_link 85 | self.answer_promise = answer_promise 86 | self.final_workspace_promise = final_workspace_promise 87 | self.promises = [answer_promise, final_workspace_promise] 88 | self.scratchpad_link = scratchpad_link 89 | self.subquestions = subquestions 90 | self.predecessor_link = predecessor_link 91 | 92 | def links(self) -> List[Address]: 93 | result = [] 94 | if self.predecessor_link is not None: 95 | result.append(self.predecessor_link) 96 | result.append(self.question_link) 97 | result.append(self.scratchpad_link) 98 | for q, a, w in self.subquestions: 99 | result.extend([q, a, w]) 100 | return result 101 | 102 | def to_str(self, display_map: Optional[Dict[Address, str]]=None) -> str: 103 | builder = [] 104 | if self.predecessor_link is not None: 105 | if display_map is None: 106 | predecessor = str(self.predecessor_link) 107 | else: 108 | predecessor = display_map[self.predecessor_link] 109 | builder.append("Predecessor:") 110 | builder.append(indent(predecessor, " ")) 111 | if display_map is None: 112 | question = str(self.question_link) 113 | scratchpad = str(self.scratchpad_link) 114 | subquestions = str(self.subquestions) 115 | else: 116 | question = display_map[self.question_link] 117 | scratchpad = display_map[self.scratchpad_link] 118 | subquestions = "\n".join("{}.\n{},\n{},\n{}".format( 119 | i, 120 | indent(display_map[q], " "), 121 | indent(display_map[a], " "), 122 | indent(display_map[w], " "), 123 | ) for i, (q, a, w) in enumerate(self.subquestions, start=1)) 124 | builder.append("Question:") 125 | builder.append(indent(question, " ")) 126 | builder.append("Scratchpad:") 127 | builder.append(indent(scratchpad, " ")) 128 | builder.append("Subquestions:") 129 | builder.append(indent(subquestions, " ")) 130 | return "\n".join(builder) 131 | 132 | -------------------------------------------------------------------------------- /patchwork/interface.py: -------------------------------------------------------------------------------- 1 | import cmd 2 | import os 3 | 4 | from traceback import print_exc 5 | from typing import Optional 6 | 7 | import parsy 8 | 9 | from .actions import Action, AskSubquestion, Reply, Unlock, Scratch 10 | from .context import Context 11 | from .datastore import Datastore 12 | from .scheduling import Session 13 | 14 | class UserInterface(cmd.Cmd): 15 | prompt = "> " 16 | 17 | def __init__(self, session: Session) -> None: 18 | super().__init__() 19 | self.session = session 20 | self.current_context = session.current_context 21 | self.initial_context = self.current_context 22 | self.update_prompt() 23 | 24 | def update_prompt(self) -> None: 25 | self.prompt = "{}\n{}".format(str(self.current_context), UserInterface.prompt) 26 | 27 | def precmd(self, line: str) -> str: 28 | os.system("cls" if os.name == "nt" else "clear") 29 | return line 30 | 31 | def emptyline(self) -> bool: 32 | return False 33 | 34 | def postcmd(self, stop: bool, line: str) -> bool: 35 | self.update_prompt() 36 | return stop 37 | 38 | def _do(self, prefix: str, action: Action) -> bool: 39 | try: 40 | result = self.session.act(action) 41 | if isinstance(result, Context): 42 | self.current_context = result 43 | else: 44 | print("The initial context was:\n {}".format(self.initial_context)) 45 | print("The final answer is:\n {}".format(result)) 46 | return True 47 | except parsy.ParseError as p: 48 | print("Your command was not parsed properly. Review the README for syntax.") 49 | print(p) 50 | except ValueError as v: 51 | print("Encountered an error with your command: ") 52 | print_exc() 53 | except KeyError as k: 54 | print("Encountered an error with your command: ") 55 | print_exc() 56 | return False 57 | 58 | def do_ask(self, arg: str) -> bool: 59 | "Ask a subquestion of the current question." 60 | return self._do("ask", AskSubquestion(arg)) 61 | 62 | def do_reply(self, arg: str) -> bool: 63 | "Provide a response to the current question." 64 | return self._do("reply", Reply(arg)) 65 | 66 | def do_unlock(self, arg: str) -> bool: 67 | "Unlock a pointer in the current workspace." 68 | return self._do("unlock", Unlock(arg)) 69 | 70 | def do_scratch(self, arg: str) -> bool: 71 | "Rewrite the Scratchpad." 72 | return self._do("scratch", Scratch(arg)) 73 | 74 | def do_exit(self, arg: str) -> bool: 75 | "Leave the program, saving if a file was specified." 76 | return True 77 | 78 | 79 | -------------------------------------------------------------------------------- /patchwork/main.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import sys 3 | 4 | from .datastore import Datastore 5 | from .scheduling import RootQuestionSession, Scheduler 6 | from .interface import UserInterface 7 | from .text_manipulation import make_link_texts 8 | 9 | 10 | def main(argv): 11 | if len(argv) > 1: 12 | fn = argv[1] 13 | try: 14 | with open(fn, 'rb') as f: 15 | db, sched = pickle.load(f) 16 | except FileNotFoundError: 17 | print("File '{}' not found, creating...".format(fn)) 18 | db = Datastore() 19 | sched = Scheduler(db) 20 | else: 21 | db = Datastore() 22 | sched = Scheduler(db) 23 | print("What is your root question?") 24 | with RootQuestionSession(sched, input("> ")) as sess: 25 | if sess.root_answer: 26 | print("Could answer question immediately based on cached data: ") 27 | print(sess.root_answer) 28 | else: 29 | ui = UserInterface(sess) 30 | ui.cmdloop() 31 | 32 | if len(argv) > 1: 33 | with open(argv[1], "wb") as f: 34 | pickle.dump((db, sched), f) 35 | 36 | if __name__ == "__main__": 37 | main(sys.argv) 38 | 39 | -------------------------------------------------------------------------------- /patchwork/scheduling.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from typing import Deque, Dict, Iterator, List, Optional, Set, Tuple, TypeVar, \ 3 | Union 4 | 5 | from .actions import Action 6 | from .context import Context 7 | from .datastore import Address, Datastore, TransactionAccumulator 8 | from .hypertext import Workspace 9 | 10 | from .text_manipulation import insert_raw_hypertext, make_link_texts 11 | 12 | 13 | # Credits: Adapted from first_true in itertools docs. 14 | T = TypeVar('T') 15 | VT = TypeVar('VT') 16 | def next_truthy(iterator: Iterator[T], default: VT) -> Union[T, VT]: 17 | """Return the next truthy value in ``iterator``. 18 | 19 | If no truthy value is found, return ``default``. 20 | """ 21 | return next(filter(None, iterator), default) 22 | 23 | 24 | # What is the scheduler's job, what is the automator's job, and what is the session's job? 25 | # Well, it seems like the session should know which contexts are "active", at least, and 26 | # therefore which ones are available to be worked on. 27 | 28 | # The scheduler also needs to be responsible for coordinating automators, which means that 29 | # when a session records an action, the scheduler needs to check to see whether that action 30 | # will create a cycle, and only actually perform the action if it does not create a cycle. 31 | # This seems kind of scary since action execution (right now) can modify the state of the 32 | # database by fulfilling real promises. This cannot then be undone. Maybe a better solution 33 | # here would be for action execution to return a representation of their effects instead. 34 | # I think promise fulfilment is the only scary thing here, though? Scratchpad editing seems 35 | # straightforwardly fine, asking subquestions is scary from the perspective of 36 | # "we might accidentally automate something that produces an infinite loop of subquestions" but 37 | # is not scary if you avoid that problem (you might end up with some uncollected garbage 38 | # in the datastore, but no infinite loops due to the workspace itself. Forgotten workspaces are 39 | # forgotten, and they don't prescribe any actions). 40 | 41 | # An automator clearly has to have a method which accepts a context and produces something. 42 | # Should it produce contexts? This seems plausible to me. However it could instead produce some 43 | # form of actions. 44 | 45 | # Some stuff about cycle detection: 46 | 47 | # Hm. What kind of cycles actually matter? 48 | 49 | # Budgets should circumvent this problem. 50 | 51 | # It's actually fine to produce a subquestion that _looks_ identical to the 52 | # parent question, and in fact this is one way automation can work at all. 53 | # It's only not fine to produce a subquestion that _is_ the same as a parent 54 | # question, down to... what, exactly? Obviously ending up with a workspace 55 | # who's its own ancestor in the subquestion graph is bad. But you can end up 56 | # with this situation in annoying ways. 57 | 58 | # `A -> B [1] -> C -> B [2]` is fine. 59 | # `A -> B -> C -> B` is not. 60 | 61 | # But imagine you actually get `A -> (B, C)`, `B -> C`, and `C -> B`. You don't 62 | # want to privilege `B -> C` or `C -> B` over the other. But in some sense you 63 | # have to: You can't block actions, or else you have to sometimes show a user a 64 | # random error message. 65 | 66 | # So you have to present the temporally _second_ creator of `B -> C` or `C -> B` 67 | # the error message immediately (this is an unfortunate but necessary "side channel"). 68 | # The problem that _this_ produces is that `C -> B` may be created _automatically_. 69 | 70 | # Imagine that you have previously seen that `A -> B [(locked) 1] -> C`. So you stored 71 | # that `B $1` produces `ask C`. But now you see that `C -> B [(locked) 2]`. 72 | # `B [(locked) 2]` is a different workspace from `B $1`, so the naive workspace-based 73 | # checking doesn't notice anything wrong until it tries to look at actions produced by 74 | # `C`, at which time it's too late (since C has already been scheduled). There is now 75 | # no way out of the trap. 76 | 77 | # I _think_ that you instead need to do the checking based on contexts: When you perform 78 | # an action, if you did all the automated steps this action implies, would you end up 79 | # with any copies of your starting workspace? 80 | 81 | # This implies a sort of automation-first approach that is pretty different from the 82 | # original way I wrote the scheduler, and might produce a much slower user experience 83 | # if the system gets big and highly automated. 84 | 85 | # Yeah, I think the best thing to do is basically do all possible automation up front inside 86 | # a transaction, as soon as an action is taken, letting bits throw exceptions if a cycle 87 | # would be created. When that happens, we can discard the transaction; otherwise we commit it. 88 | 89 | 90 | class Automator(object): 91 | def can_handle(self, context: Context) -> bool: 92 | raise NotImplementedError("Automator is pure virtual") 93 | 94 | def handle(self, context: Context) -> Action: 95 | raise NotImplementedError("Automator is pure virtual") 96 | 97 | 98 | class Memoizer(Automator): 99 | """A memoizer for H's actions. 100 | 101 | This memoizer learns H's mapping ``str(Context) → Action`` and can be called 102 | instead of H for all string representations of contexts that it has seen. 103 | """ 104 | def __init__(self): 105 | self.cache: Dict[str, Action] = {} 106 | 107 | def remember(self, context: Context, action: Action): 108 | self.cache[str(context)] = action 109 | 110 | def forget(self, context: Context): 111 | del self.cache[str(context)] 112 | 113 | def can_handle(self, context: Context) -> bool: 114 | return str(context) in self.cache 115 | 116 | def handle(self, context: Context) -> Action: 117 | return self.cache[str(context)] 118 | 119 | 120 | class Scheduler(object): 121 | def __init__(self, db: Datastore) -> None: 122 | self.db = db 123 | 124 | # Contexts that are currently being shown to a user 125 | self.active_contexts: Set[Context] = set() 126 | 127 | # Contexts that are waiting to be shown to a user because 128 | # they cannot be automated. 129 | 130 | # (note that these semantics mean that we must iterate over these contexts 131 | # every time the automatability criteria change) 132 | self.pending_contexts: Deque[Context] = deque([]) 133 | 134 | # Things that can automate work - only the memoizer for now, though we could add 135 | # calculators, programs, macros, distilled agents, etc. 136 | self.memoizer = Memoizer() 137 | self.automators: List[Automator] = [self.memoizer] 138 | 139 | def ask_root_question(self, contents: str) -> Tuple[Context, Address]: 140 | # How root! 141 | question_link = insert_raw_hypertext(contents, self.db, {}) 142 | answer_link = self.db.make_promise() 143 | final_workspace_link = self.db.make_promise() 144 | scratchpad_link = insert_raw_hypertext("", self.db, {}) 145 | new_workspace = Workspace(question_link, answer_link, final_workspace_link, scratchpad_link, []) 146 | new_workspace_link = self.db.insert(new_workspace) 147 | result = Context(new_workspace_link, self.db) 148 | answer_link = self.db.dereference(result.workspace_link).answer_promise 149 | self.active_contexts.add(result) 150 | while self.memoizer.can_handle(result): 151 | result = self.resolve_action(result, self.memoizer.handle(result)) 152 | 153 | return result, answer_link 154 | 155 | def resolve_action(self, starting_context: Context, action: Action) -> Optional[Context]: 156 | # NOTE: There's a lot of wasted work in here for the sake of rolling back cycle-driven mistakes. 157 | # This stuff could all be removed if we had budgets. 158 | assert starting_context in self.active_contexts 159 | transaction = TransactionAccumulator(self.db) 160 | self.memoizer.remember(starting_context, action) 161 | 162 | try: 163 | successor, other_contexts = action.execute(transaction, starting_context) 164 | un_automatable_contexts: List[Context] = [] 165 | possibly_automatable_contexts = deque(self.pending_contexts) 166 | possibly_automatable_contexts.extendleft(other_contexts) 167 | 168 | while len(possibly_automatable_contexts) > 0: 169 | context = possibly_automatable_contexts.popleft() 170 | automatic_action = None 171 | for automator in self.automators: 172 | if automator.can_handle(context): 173 | automatic_action = automator.handle(context) 174 | break 175 | if automatic_action is not None: 176 | new_successor, new_contexts = automatic_action.execute(transaction, context) 177 | if new_successor is not None: # in the automated setting, successors are not special. 178 | new_contexts.append(new_successor) 179 | for context in new_contexts: 180 | if context.is_own_ancestor(transaction): # So much waste 181 | raise ValueError("Action resulted in an infinite loop") 182 | possibly_automatable_contexts.append(context) 183 | else: 184 | un_automatable_contexts.append(context) 185 | 186 | transaction.commit() 187 | self.pending_contexts = deque(un_automatable_contexts) 188 | self.active_contexts.remove(starting_context) 189 | if successor is not None: 190 | self.active_contexts.add(successor) 191 | return successor 192 | except: 193 | self.memoizer.forget(starting_context) 194 | raise 195 | 196 | def choose_context(self, promise: Address) -> Context: 197 | """Return a context that can advance ``promise``.""" 198 | choice = next(c for c in self.pending_contexts 199 | if c.can_advance_promise(self.db, promise)) 200 | self.pending_contexts.remove(choice) 201 | self.active_contexts.add(choice) 202 | return choice 203 | 204 | def relinquish_context(self, context: Context) -> None: 205 | self.pending_contexts.append(context) 206 | self.active_contexts.remove(context) 207 | 208 | 209 | class Session(object): 210 | def __init__(self, scheduler: Scheduler) -> None: 211 | self.sched = scheduler 212 | # current_context is None before and after the session is complete. 213 | self.current_context: Optional[Context] = None 214 | 215 | def __enter__(self): 216 | return self 217 | 218 | def __exit__(self, *args): 219 | # Handle cleanup. Things we need to do here: 220 | # Tell the scheduler that we relinquish our current context. 221 | if self.current_context is not None and self.current_context in self.sched.active_contexts: 222 | self.sched.relinquish_context(self.current_context) 223 | 224 | def act(self, action: Action) -> Union[Context, str]: 225 | raise NotImplementedError("Sessions must implement act()") 226 | 227 | 228 | class RootQuestionSession(Session): 229 | # A root question session is only interested in displaying contexts 230 | # that are making progress toward its goal question. This is the only 231 | # kind of session the command line app, though there would be "aimless" 232 | # sessions in a real app. 233 | def __init__(self, scheduler: Scheduler, question: str) -> None: 234 | super().__init__(scheduler) 235 | self.current_context, self.root_answer_promise = \ 236 | scheduler.ask_root_question(question) 237 | self.root_answer = None 238 | 239 | promise_to_advance = self.choose_promise(self.root_answer_promise) 240 | if promise_to_advance is None: # Ie. everything was answered. 241 | self.format_root_answer() 242 | else: 243 | self.current_context = \ 244 | self.current_context \ 245 | or self.sched.choose_context(promise_to_advance) 246 | 247 | def choose_promise(self, root: Address) -> Optional[Address]: 248 | """Return unfulfilled promise from hypertext tree with root ``root``. 249 | 250 | Parameters 251 | ---------- 252 | root 253 | Address pointing at hypertext or a promise of hypertext. 254 | 255 | Note 256 | ---- 257 | Don't confuse the root node of a hypertext tree with the root question. 258 | 259 | Examples 260 | -------- 261 | * If no reply was given to the root question yet, return the root 262 | question promise. 263 | * If the reply to the root question was given and is "To seek the 264 | Holy Grail.", return ``None``, because there is nothing left to do. 265 | * If the reply to the root question was given and is "Looks like a $a2." 266 | (from the perspective of the replier), return the promise that $a2 267 | points to. This is done recursively, so if $a2 is resolved to 268 | something that points to another promise p, return p. 269 | 270 | Purpose 271 | ------- 272 | A hypertext object is *complete* when all its pointers are unlocked and 273 | the hypertext objects they point to are complete. Ie. "Looks like a 274 | [penguin]." and "What is your quest?" are complete, but "What is the 275 | capital of $1?" is not. 276 | 277 | After the root answer promise is resolved, the root answer might not yet 278 | be complete. The asker of the root question wants a complete answer, but 279 | she can't interact with it to unlock pointers. Instead, this method 280 | finds any promises that the root answer points to. The caller can then 281 | schedule contexts to resolve these promises. 282 | """ 283 | if not self.sched.db.is_fulfilled(root): 284 | return root 285 | 286 | return next_truthy((self.choose_promise(child) 287 | for child in self.sched.db.dereference(root).links()), 288 | None) 289 | 290 | def format_root_answer(self) -> str: 291 | """Format the root answer with all its pointers unlocked.""" 292 | self.root_answer = make_link_texts( 293 | self.root_answer_promise, 294 | self.sched.db)[self.root_answer_promise] 295 | return self.root_answer 296 | 297 | def act(self, action: Action) -> Union[Context, str]: 298 | resulting_context = self.sched.resolve_action(self.current_context, 299 | action) 300 | 301 | promise_to_advance = self.choose_promise(self.root_answer_promise) 302 | if promise_to_advance is None: # Ie. everything was answered. 303 | return self.format_root_answer() 304 | 305 | self.current_context = resulting_context \ 306 | or self.sched.choose_context(promise_to_advance) 307 | 308 | return self.current_context 309 | -------------------------------------------------------------------------------- /patchwork/text_manipulation.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, deque 2 | from typing import Any, DefaultDict, Dict, List, Optional, Set, Union 3 | 4 | import parsy 5 | 6 | from .datastore import Address, Datastore 7 | from .hypertext import RawHypertext, visit_unlocked_region 8 | 9 | link = parsy.regex(r"\$([awq]?[1-9][0-9]*)") 10 | otherstuff = parsy.regex(r"[^\[\$\]]+") 11 | 12 | lbrack = parsy.string("[") 13 | rbrack = parsy.string("]") 14 | 15 | @parsy.generate 16 | def subnode(): 17 | yield lbrack 18 | result = yield hypertext 19 | yield rbrack 20 | return result 21 | 22 | hypertext = (link | subnode | otherstuff).many() 23 | 24 | # MyPy can't deal with this yet 25 | # ParsePiece = Union[str, "ParsePiece"] 26 | ParsePiece = Any 27 | 28 | def recursively_create_hypertext( 29 | pieces: List[ParsePiece], 30 | db: Datastore, 31 | pointer_link_map: Dict[str, Address] 32 | ) -> RawHypertext: 33 | result: List[Union[Address, str]] = [] 34 | for piece in pieces: 35 | if isinstance(piece, list): 36 | result.append(recursively_insert_hypertext(piece, db, pointer_link_map)) 37 | else: 38 | try: 39 | # This is a link that should be in the map 40 | result.append(pointer_link_map[link.parse(piece)]) 41 | except parsy.ParseError: 42 | # This is just a regular string 43 | result.append(piece) 44 | return RawHypertext(result) 45 | 46 | 47 | def recursively_insert_hypertext( 48 | pieces: List[ParsePiece], 49 | db: Datastore, 50 | pointer_link_map: Dict[str, Address] 51 | ) -> Address: 52 | result = db.insert(recursively_create_hypertext(pieces, db, pointer_link_map)) 53 | return result 54 | 55 | 56 | def insert_raw_hypertext( 57 | content: str, 58 | db: Datastore, 59 | pointer_link_map: Dict[str, Address], 60 | ) -> Address: 61 | parsed = hypertext.parse(content) 62 | return recursively_insert_hypertext(parsed, db, pointer_link_map) 63 | 64 | 65 | def create_raw_hypertext( 66 | content: str, 67 | db: Datastore, 68 | pointer_link_map: Dict[str, Address] 69 | ) -> RawHypertext: 70 | parsed = hypertext.parse(content) 71 | return recursively_create_hypertext(parsed, db, pointer_link_map) 72 | 73 | 74 | def make_link_texts( 75 | root_link: Address, 76 | db: Datastore, 77 | unlocked_locations: Optional[Set[Address]]=None, 78 | pointer_names: Optional[Dict[Address, str]]=None, 79 | ) -> Dict[Address, str]: 80 | INLINE_FMT = "[{pointer_name}: {content}]" 81 | ANONYMOUS_INLINE_FMT = "[{content}]" 82 | # We need to construct this string in topological order since pointers 83 | # are substrings of other unlocked pointers. Since everything is immutable 84 | # once created, we are guaranteed to have a DAG. 85 | include_counts: DefaultDict[Address, int] = defaultdict(int) 86 | 87 | for link in visit_unlocked_region(root_link, root_link, db, unlocked_locations): 88 | page = db.dereference(link) 89 | for visible_link in page.links(): 90 | include_counts[visible_link] += 1 91 | 92 | assert(include_counts[root_link] == 0) 93 | 94 | no_incomings = deque([root_link]) 95 | order: List[Address] = [] 96 | while len(no_incomings) > 0: 97 | link = no_incomings.popleft() 98 | order.append(link) 99 | if unlocked_locations is None or link in unlocked_locations: 100 | page = db.dereference(link) 101 | for outgoing_link in page.links(): 102 | include_counts[outgoing_link] -= 1 103 | if include_counts[outgoing_link] == 0: 104 | no_incomings.append(outgoing_link) 105 | 106 | link_texts: Dict[Address, str] = {} 107 | 108 | if pointer_names is not None: 109 | for link in reversed(order): 110 | if link == root_link: 111 | continue 112 | if unlocked_locations is not None and link not in unlocked_locations: 113 | link_texts[link] = pointer_names[link] 114 | else: 115 | page = db.dereference(link) 116 | link_texts[link] = INLINE_FMT.format( 117 | pointer_name=pointer_names[link], 118 | content=page.to_str(display_map=link_texts)) 119 | else: 120 | for link in reversed(order): 121 | page = db.dereference(link) 122 | link_texts[link] = ANONYMOUS_INLINE_FMT.format( 123 | content=page.to_str(display_map=link_texts)) 124 | 125 | 126 | return link_texts 127 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs=18.2.0 2 | parsy==1.2.0 3 | -------------------------------------------------------------------------------- /sorted_list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oughtinc/patchwork/a7acbc5489f098dac64d90b5a8efc583b1857750/sorted_list.gif -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for patchwork. 2 | 3 | There aren't many yet. Execute them like this:: 4 | 5 | $ cd 6 | $ python3.6 -m unittest discover 7 | """ 8 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from patchwork.actions import AskSubquestion, Reply, Unlock 4 | from patchwork.datastore import Datastore 5 | from patchwork.scheduling import RootQuestionSession, Scheduler 6 | 7 | 8 | class TestBasic(unittest.TestCase): 9 | """Integration tests for basic scenarios.""" 10 | 11 | # Note: This test is tightly coupled with the implementation. If one of your 12 | # changes makes the test fail, it might not be because your change is wrong, 13 | # but because of the coupling. 14 | def testRecursion(self): 15 | """Test the recursion example from the taxonomy. 16 | 17 | Cf. https://ought.org/projects/factored-cognition/taxonomy#recursion 18 | """ 19 | db = Datastore() 20 | sched = Scheduler(db) 21 | 22 | with RootQuestionSession(sched, "What is 351 * 5019?") as sess: 23 | self.assertRegex(str(sess.current_context), 24 | r"Question: .*What is 351 \* 5019\?") 25 | 26 | sess.act(AskSubquestion("What is 300 * 5019?")) 27 | context = sess.act(AskSubquestion("What is 50 * 5019?")) 28 | self.assertIn("$q1: What is 300 * 5019?", str(context)) 29 | self.assertIn("$q2: What is 50 * 5019?", str(context)) 30 | 31 | for pid in ["$a1", "$a2"]: # pid = pointer ID 32 | context = sess.act(Unlock(pid)) 33 | self.assertRegex(str(sess.current_context), 34 | r"Question: .*What is (?:300|50) \* 5019\?") 35 | if "300" in str(context): 36 | context = sess.act(Reply("1505700")) 37 | else: 38 | context = sess.act(Reply("250950")) 39 | 40 | self.assertIn("$a1: 1505700", str(context)) 41 | self.assertIn("$a2: 250950", str(context)) 42 | 43 | sess.act(AskSubquestion("What is 1505700 + 250950 + 5019?")) 44 | sess.act(Unlock("$a3")) 45 | context = sess.act(Reply("1761669")) 46 | 47 | self.assertIn("$q3: What is 1505700 + 250950 + 5019?", str(context)) 48 | self.assertIn("$a3: 1761669", str(context)) 49 | 50 | result = sess.act(Reply("1761669")) 51 | self.assertIsNotNone(sess.root_answer) 52 | self.assertIn("1761669", result) 53 | 54 | 55 | # The following tests are incomplete in that they only make sure that no 56 | # exceptions are thrown. Since the scheduler throws an exception when there 57 | # are no blocking contexts left, this implicitly asserts that the scheduler 58 | # doesn't overlook blocking contexts. 59 | 60 | def testRootReplyWithPointers(self): 61 | """Test whether root replies with pointers work.""" 62 | db = Datastore() 63 | sched = Scheduler(db) 64 | 65 | with RootQuestionSession(sched, "Root?") as sess: 66 | sess.act(AskSubquestion("Sub1?")) 67 | sess.act(AskSubquestion("Sub2?")) 68 | sess.act(Reply("Root [$a1 $a2].")) 69 | 70 | 71 | def testNonRootPromise(self): 72 | """Test whether a non-root promise gets advanced.""" 73 | db = Datastore() 74 | sched = Scheduler(db) 75 | 76 | with RootQuestionSession(sched, "Root?") as sess: 77 | sess.act(AskSubquestion("Sub1?")) 78 | sess.act(AskSubquestion("Sub2 ($a1)?")) 79 | sess.act(Reply("$a2")) 80 | sess.act(Unlock("$3")) 81 | 82 | 83 | def testUnlockWorkspace(self): 84 | """Test unlocking of unfulfilled workspaces.""" 85 | db = Datastore() 86 | sched = Scheduler(db) 87 | 88 | with RootQuestionSession(sched, "Root?") as sess: 89 | sess.act(AskSubquestion("Sub1?")) 90 | sess.act(Unlock("$w1")) 91 | 92 | 93 | def testUnlockedLockedPointer(self): 94 | """Test whether root reply with an unlocked and a locked pointer works. 95 | """ 96 | db = Datastore() 97 | sched = Scheduler(db) 98 | 99 | with RootQuestionSession(sched, "Root?") as sess: 100 | sess.act(AskSubquestion("Sub1?")) 101 | sess.act(Reply("$q1 $a1")) 102 | 103 | 104 | def testEqualStringRepresentation(self): 105 | """Test processing of contexts with equal string representation. 106 | 107 | If two contexts have the same string representation that includes a 108 | locked pointer, make sure: 109 | 110 | * That none of the contexts gets dropped. 111 | * That when a reply is given in the first context, the 112 | :py:class:`patchwork.Memoizer` correctly outputs the same reply in 113 | the second context. 114 | """ 115 | db = Datastore() 116 | sched = Scheduler(db) 117 | 118 | with RootQuestionSession(sched, "Bicycle Repair Man, but how?") as sess: 119 | sess.act(AskSubquestion("Is it a [stockbroker]?")) 120 | sess.act(AskSubquestion("Is it a [quantity surveyor]?")) 121 | sess.act(Reply("$a1 $a2")) 122 | sess.act(Reply("NO! It's Bicycle Repair Man.")) 123 | self.assertEqual("[[NO! It's Bicycle Repair Man.]" 124 | " [NO! It's Bicycle Repair Man.]]", 125 | sess.root_answer) 126 | -------------------------------------------------------------------------------- /tests/test_laziness.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from patchwork.actions import Unlock, AskSubquestion 4 | from patchwork.datastore import Datastore 5 | from patchwork.scheduling import RootQuestionSession, Scheduler 6 | 7 | 8 | class LazinessTest(unittest.TestCase): 9 | def testLaziness(self): 10 | """ 11 | Schedule context for which unlock is waiting, not top-of-stack context. 12 | """ 13 | db = Datastore() 14 | sched = Scheduler(db) 15 | 16 | with RootQuestionSession(sched, "Root question?") as sess: 17 | sess.act(AskSubquestion("Question 1?")) 18 | sess.act(AskSubquestion("Question 2?")) 19 | sess.act(AskSubquestion("Question 3?")) 20 | sess.act(AskSubquestion("Question 4?")) 21 | context = sess.act(Unlock("$a2")) 22 | self.assertIn("Question 2?", str(context)) 23 | --------------------------------------------------------------------------------