diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..5279dd30f1c429da2e648958542f4df12a918cda 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+docs/images/david-demos.png filter=lfs diff=lfs merge=lfs -text
+docs/images/david-evidence.png filter=lfs diff=lfs merge=lfs -text
+docs/images/david-execution.png filter=lfs diff=lfs merge=lfs -text
+docs/images/david-prolog.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..99d58929e0d7dac7d03d15b190c752e77121e480
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+__pycache__/
+*.py[cod]
+*.egg-info/
+.Python
+.venv/
+venv/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..be3f7b28e564e7dd05eaf59d64adba1a4065ac0e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..fe7f9139e1943995fbdfaa41693b7b1bb5918930
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+---
+license: agpl-3.0
+tags:
+- snapkitty
+- october-2026-drop
+- j
+- prolog
+- postgresql
+- pgvector
+- python
+- mixture-of-agents
+- router
+- agents
+---
+
+> Mirrored from [https://github.com/SNAPKITTYWEST/david](https://github.com/SNAPKITTYWEST/david) at commit `4ecbee9`. Part of the **SnapKitty October 2026 main drop**.
+
diff --git a/docs/contracts.md b/docs/contracts.md
new file mode 100644
index 0000000000000000000000000000000000000000..5d513af7815f82ffc2fbe23d35e15131d254b4cf
--- /dev/null
+++ b/docs/contracts.md
@@ -0,0 +1,45 @@
+# David contracts
+
+## WorkItem (agent input)
+
+```
+request_id, work_id, agent_id
+task, evidence_bundle, required_outputs
+permissions, deadline, risk_policy
+```
+
+## AgentResult (agent output)
+
+```
+agent_id
+facts[], conclusions[], assumptions[], uncertainty[]
+evidence_refs[], transformations[]
+risk_signals[], provenance_root, status
+```
+
+## Evidence / EvidenceBundle
+
+See architecture §5. Every chunk carries source, hash, authority, and parent links —
+agents never receive bare text without provenance.
+
+## Execution wiring
+
+`execution/` builds one WorkItem per selected `agent_id` and dispatches only that set.
+Unselected agents (e.g. Quant on a COBOL migration query) never receive work.
+
+## Symbolic hard filters (`router/symbolic.py`)
+
+Predicates (not score penalties). An agent is eligible only if all three hold:
+
+- **capability_eligible** — `capabilities ∩ required_capabilities ≠ ∅`, or `control_plane`
+- **permission_eligible** — `permissions ⊆ granted_permissions`
+- **risk_eligible** — `risk_class` does not exceed `max_risk_class` (`low < medium < high < critical`)
+
+`apply_symbolic_filters` returns the agents that pass all three.
+`minimum_agent_set` scores via `scorer.score_agent`, takes top-K, hard-filters, greedy-covers required capabilities, closes dependencies, then appends mandatory control-plane agents (`Risk`, `Response`). Pure dependency agents waive capability_eligible; after closure every agent must still pass permission + risk (else the parent needing a failing dep is dropped). Mandatory control plane skips the risk gate but still requires permission (same as scorer `include_control_plane`).
+
+## J execution (product)
+
+`j/execution.ijs` consumes the Prolog selected-set (one `agent_id` per line) and builds
+WorkItems only for that set. `bundle_ok` requires each evidence row to carry non-empty
+`evidence_id`, `source_id`, and `content_hash` — bare text is rejected.
diff --git a/docs/images/.gitkeep b/docs/images/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/docs/images/david-demos.png b/docs/images/david-demos.png
new file mode 100644
index 0000000000000000000000000000000000000000..cdb792bc363b05452336dc27d8577f00d71efdab
--- /dev/null
+++ b/docs/images/david-demos.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e546b51f7641bd4cc92acb9348a8d3b583600679a1ce0c61b4616bdc2c9405bd
+size 109045
diff --git a/docs/images/david-evidence.png b/docs/images/david-evidence.png
new file mode 100644
index 0000000000000000000000000000000000000000..cdb792bc363b05452336dc27d8577f00d71efdab
--- /dev/null
+++ b/docs/images/david-evidence.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e546b51f7641bd4cc92acb9348a8d3b583600679a1ce0c61b4616bdc2c9405bd
+size 109045
diff --git a/docs/images/david-execution.png b/docs/images/david-execution.png
new file mode 100644
index 0000000000000000000000000000000000000000..cdb792bc363b05452336dc27d8577f00d71efdab
--- /dev/null
+++ b/docs/images/david-execution.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e546b51f7641bd4cc92acb9348a8d3b583600679a1ce0c61b4616bdc2c9405bd
+size 109045
diff --git a/docs/images/david-prolog.png b/docs/images/david-prolog.png
new file mode 100644
index 0000000000000000000000000000000000000000..cdb792bc363b05452336dc27d8577f00d71efdab
--- /dev/null
+++ b/docs/images/david-prolog.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e546b51f7641bd4cc92acb9348a8d3b583600679a1ce0c61b4616bdc2c9405bd
+size 109045
diff --git a/evidence/__init__.py b/evidence/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9f7acf3211189d7d017f75ea35d51790a065992c
--- /dev/null
+++ b/evidence/__init__.py
@@ -0,0 +1,17 @@
+"""In-memory evidence retrieval (mirrors sql/001 hybrid sketch; no live DB)."""
+
+from .retrieve import (
+ EvidenceRecord,
+ EvidenceIndex,
+ retrieve,
+ seed_cobol_corpus,
+ empty_bundle,
+)
+
+__all__ = [
+ "EvidenceRecord",
+ "EvidenceIndex",
+ "retrieve",
+ "seed_cobol_corpus",
+ "empty_bundle",
+]
diff --git a/evidence/retrieve.py b/evidence/retrieve.py
new file mode 100644
index 0000000000000000000000000000000000000000..955315ad0753d01f7899769f6b1d6e2835c62796
--- /dev/null
+++ b/evidence/retrieve.py
@@ -0,0 +1,346 @@
+"""Hybrid evidence retrieval — dense + lexical + metadata + provenance.
+
+In-memory stand-in for sql/001_agent_and_evidence_indexes.sql.
+Production SQL uses vector(1536); tests use fixed dim-8 vectors (no live DB).
+
+Merge / rerank rule (deterministic):
+ score = dense_cosine + lexical_overlap + source_authority - penalties
+ sort by (-score, evidence_id)
+ penalties: missing required parent_evidence when policy requires it
+
+Hybrid note: a chunk with weak dense but strong lexical can still rank
+if lexical_overlap compensates (no hard dense floor).
+"""
+
+from __future__ import annotations
+
+import hashlib
+import math
+import re
+from dataclasses import dataclass, field
+from typing import Any, Iterable, Sequence
+
+
+# Production pgvector column is vector(1536); test corpus uses tiny fixed vectors.
+EMBEDDING_DIM_TEST = 8
+EMBEDDING_DIM_PRODUCTION = 1536
+
+
+@dataclass
+class EvidenceRecord:
+ """Fields mirror the SQL ``evidence`` table."""
+
+ evidence_id: str
+ source_id: str
+ source_type: str
+ document_id: str | None
+ location: str | None
+ content: str
+ content_hash: str
+ embedding: list[float] | None
+ source_authority: float = 0.5
+ parent_evidence: tuple[str, ...] = ()
+ created_at: str | None = None
+
+
+def _content_hash(text: str) -> str:
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
+
+
+def _tokenize(text: str) -> set[str]:
+ return {t for t in re.findall(r"[a-z0-9]+", text.lower()) if len(t) > 1}
+
+
+def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
+ if not a or not b or len(a) != len(b):
+ return 0.0
+ dot = sum(x * y for x, y in zip(a, b))
+ na = math.sqrt(sum(x * x for x in a))
+ nb = math.sqrt(sum(y * y for y in b))
+ if na == 0.0 or nb == 0.0:
+ return 0.0
+ return dot / (na * nb)
+
+
+def lexical_score(query: str, content: str) -> float:
+ """Token overlap stand-in for ``content_tsv @@ plainto_tsquery``."""
+ q = _tokenize(query)
+ if not q:
+ return 0.0
+ c = _tokenize(content)
+ if not c:
+ return 0.0
+ return len(q & c) / len(q)
+
+
+def empty_bundle() -> dict[str, Any]:
+ """EvidenceBundle-shaped dict with no hits (contracts.md)."""
+ return {
+ "facts": [],
+ "sources": [],
+ "provenance": [],
+ "retrieval_metadata": [],
+ }
+
+
+def seed_cobol_corpus() -> list[EvidenceRecord]:
+ """Fixed COBOL-modernization corpus (dim-8 embeddings for tests).
+
+ Axes (approx): [cobol, balance, payment, schema, pricing, vol, greek, junk]
+ """
+ # Strong COBOL / balance / payment cluster
+ copybook = EvidenceRecord(
+ evidence_id="ev_copybook_01",
+ source_id="src_copybook_acct",
+ source_type="copybook",
+ document_id="ACCTREC.cpy",
+ location="01-ACCOUNT-BALANCE",
+ content=(
+ "01 ACCOUNT-RECORD. "
+ "05 ACCOUNT-BALANCE PIC S9(9)V99. "
+ "05 PAYMENT-AMOUNT PIC S9(7)V99. "
+ "05 LAST-PAYMENT-DATE PIC X(8)."
+ ),
+ content_hash="",
+ embedding=[0.95, 0.90, 0.85, 0.20, 0.05, 0.0, 0.0, 0.0],
+ source_authority=0.95,
+ parent_evidence=("ev_provenance_root",),
+ created_at="2026-01-01T00:00:00Z",
+ )
+ cobol_src = EvidenceRecord(
+ evidence_id="ev_cobol_src_01",
+ source_id="src_cobol_pay",
+ source_type="cobol_source",
+ document_id="PAYROLL.cbl",
+ location="PROCEDURE DIVISION / CALC-BALANCE",
+ content=(
+ "COMPUTE ACCOUNT-BALANCE = ACCOUNT-BALANCE - PAYMENT-AMOUNT. "
+ "IF ACCOUNT-BALANCE < ZERO THEN PERFORM OVERDRAFT-CHECK. "
+ "MOVE PAYMENT-AMOUNT TO WS-LAST-PAYMENT."
+ ),
+ content_hash="",
+ embedding=[0.92, 0.88, 0.90, 0.15, 0.05, 0.0, 0.0, 0.0],
+ source_authority=0.90,
+ parent_evidence=("ev_provenance_root",),
+ created_at="2026-01-01T00:00:00Z",
+ )
+ db2 = EvidenceRecord(
+ evidence_id="ev_db2_schema_01",
+ source_id="src_db2_acct",
+ source_type="db2_schema",
+ document_id="ACCT.DDL",
+ location="TABLE ACCOUNT_BALANCES",
+ content=(
+ "CREATE TABLE ACCOUNT_BALANCES ("
+ "ACCT_ID CHAR(12), BALANCE DECIMAL(11,2), "
+ "LAST_PAYMENT DECIMAL(9,2), UPDATED_TS TIMESTAMP)."
+ ),
+ content_hash="",
+ embedding=[0.70, 0.80, 0.55, 0.95, 0.05, 0.0, 0.0, 0.0],
+ source_authority=0.85,
+ parent_evidence=("ev_provenance_root",),
+ created_at="2026-01-01T00:00:00Z",
+ )
+ # Should NOT win on a COBOL balance query (pricing / quant cluster)
+ quant = EvidenceRecord(
+ evidence_id="ev_quant_pricing_01",
+ source_id="src_quant_report",
+ source_type="quant_report",
+ document_id="PRICING_Q3.pdf",
+ location="section:monte_carlo",
+ content=(
+ "Monte Carlo pricing of exotic options. "
+ "Implied volatility surface and greek sensitivities. "
+ "No COBOL or account balance fields."
+ ),
+ content_hash="",
+ embedding=[0.05, 0.05, 0.05, 0.05, 0.95, 0.90, 0.85, 0.10],
+ source_authority=0.80,
+ parent_evidence=("ev_provenance_root",),
+ created_at="2026-01-01T00:00:00Z",
+ )
+ # Low-authority junk for provenance filter tests
+ junk = EvidenceRecord(
+ evidence_id="ev_junk_lowauth_01",
+ source_id="src_web_scrape",
+ source_type="web_scrape",
+ document_id="random-blog.html",
+ location="body",
+ content=(
+ "Someone said COBOL payment balance might work like a spreadsheet. "
+ "Unverified forum post."
+ ),
+ content_hash="",
+ embedding=[0.60, 0.55, 0.50, 0.10, 0.10, 0.0, 0.0, 0.9],
+ source_authority=0.15,
+ parent_evidence=(),
+ created_at="2026-01-01T00:00:00Z",
+ )
+ # Weak dense (far from COBOL cluster) but strong lexical on balance/payment/cobol
+ # — hybrid: lexical can lift it into results when dense alone would not.
+ lexical_lift = EvidenceRecord(
+ evidence_id="ev_lexical_lift_01",
+ source_id="src_glossary",
+ source_type="glossary",
+ document_id="LEGACY-GLOSSARY.txt",
+ location="entry:PAYMENT-BALANCE",
+ content=(
+ "cobol payment balance glossary entry: "
+ "PAYMENT and BALANCE fields on ACCOUNT records in legacy batch."
+ ),
+ content_hash="",
+ # Oriented away from COBOL cluster → weak cosine vs COBOL query
+ embedding=[0.10, 0.15, 0.10, 0.05, 0.40, 0.35, 0.30, 0.20],
+ source_authority=0.70,
+ parent_evidence=("ev_provenance_root",),
+ created_at="2026-01-01T00:00:00Z",
+ )
+
+ records = [copybook, cobol_src, db2, quant, junk, lexical_lift]
+ for r in records:
+ r.content_hash = _content_hash(r.content)
+ return records
+
+
+# Canonical COBOL payment/balance query embedding (dim 8)
+COBOL_BALANCE_QUERY = "cobol account payment balance CALC-BALANCE"
+COBOL_BALANCE_QUERY_VEC = [0.93, 0.91, 0.88, 0.25, 0.05, 0.0, 0.0, 0.0]
+
+
+@dataclass
+class EvidenceIndex:
+ """In-memory evidence index (no Postgres required)."""
+
+ records: list[EvidenceRecord] = field(default_factory=list)
+
+ def __post_init__(self) -> None:
+ if not self.records:
+ self.records = seed_cobol_corpus()
+ self._by_id = {r.evidence_id: r for r in self.records}
+
+ def add(self, record: EvidenceRecord) -> None:
+ if not record.content_hash:
+ record.content_hash = _content_hash(record.content)
+ self.records.append(record)
+ self._by_id[record.evidence_id] = record
+
+
+def retrieve(
+ query: str,
+ *,
+ query_embedding: Sequence[float] | None = None,
+ index: EvidenceIndex | None = None,
+ source_types: Iterable[str] | None = None,
+ document_ids: Iterable[str] | None = None,
+ min_authority: float = 0.0,
+ require_parent_evidence: bool = False,
+ top_k: int = 10,
+) -> dict[str, Any]:
+ """Hybrid retrieve → EvidenceBundle-shaped dict (facts/sources/provenance/…).
+
+ Agents never receive bare text without provenance fields on each source.
+ """
+ if not query or not query.strip():
+ return empty_bundle()
+
+ idx = index or EvidenceIndex()
+ st_filter = set(source_types) if source_types is not None else None
+ doc_filter = set(document_ids) if document_ids is not None else None
+ q_emb = list(query_embedding) if query_embedding is not None else None
+
+ scored: list[tuple[float, EvidenceRecord, dict[str, float]]] = []
+ for rec in idx.records:
+ # Metadata filters
+ if st_filter is not None and rec.source_type not in st_filter:
+ continue
+ if doc_filter is not None and rec.document_id not in doc_filter:
+ continue
+ # Provenance / authority
+ if rec.source_authority < min_authority:
+ continue
+ if require_parent_evidence and not rec.parent_evidence:
+ continue
+
+ dense = 0.0
+ if q_emb is not None and rec.embedding is not None:
+ dense = cosine_similarity(q_emb, rec.embedding)
+ lex = lexical_score(query, rec.content)
+ authority = float(rec.source_authority)
+ penalty = 0.0
+ if require_parent_evidence and not rec.parent_evidence:
+ penalty += 1.0 # defensive; already filtered above
+
+ score = dense + lex + authority - penalty
+ parts = {
+ "dense": dense,
+ "lexical": lex,
+ "source_authority": authority,
+ "penalty": penalty,
+ "score": score,
+ }
+ scored.append((score, rec, parts))
+
+ # Deterministic rerank: score desc, then evidence_id asc
+ scored.sort(key=lambda t: (-t[0], t[1].evidence_id))
+ top = scored[:top_k]
+
+ if not top:
+ return empty_bundle()
+
+ facts: list[dict[str, Any]] = []
+ sources: list[dict[str, Any]] = []
+ provenance: list[dict[str, Any]] = []
+ retrieval_metadata: list[dict[str, Any]] = []
+
+ for score, rec, parts in top:
+ facts.append(
+ {
+ "evidence_id": rec.evidence_id,
+ "summary": rec.content[:160],
+ "source_type": rec.source_type,
+ }
+ )
+ sources.append(
+ {
+ "evidence_id": rec.evidence_id,
+ "source_id": rec.source_id,
+ "source_type": rec.source_type,
+ "document_id": rec.document_id,
+ "location": rec.location,
+ "content_hash": rec.content_hash,
+ "source_authority": rec.source_authority,
+ "parent_evidence": list(rec.parent_evidence),
+ "content": rec.content,
+ "retrieval_score": score,
+ }
+ )
+ provenance.append(
+ {
+ "evidence_id": rec.evidence_id,
+ "source_id": rec.source_id,
+ "content_hash": rec.content_hash,
+ "parent_evidence": list(rec.parent_evidence),
+ "source_authority": rec.source_authority,
+ }
+ )
+ retrieval_metadata.append(
+ {
+ "evidence_id": rec.evidence_id,
+ "dense": parts["dense"],
+ "lexical": parts["lexical"],
+ "source_authority": parts["source_authority"],
+ "penalty": parts["penalty"],
+ "score": parts["score"],
+ "embedding_dim_note": (
+ f"test vectors dim={EMBEDDING_DIM_TEST}; "
+ f"production SQL uses vector({EMBEDDING_DIM_PRODUCTION})"
+ ),
+ }
+ )
+
+ return {
+ "facts": facts,
+ "sources": sources,
+ "provenance": provenance,
+ "retrieval_metadata": retrieval_metadata,
+ }
diff --git a/evidence/test_retrieve.py b/evidence/test_retrieve.py
new file mode 100644
index 0000000000000000000000000000000000000000..962e82a01c8a9d9553531e72de9253977750452b
--- /dev/null
+++ b/evidence/test_retrieve.py
@@ -0,0 +1,160 @@
+"""Tests for in-memory hybrid evidence retrieval."""
+
+from __future__ import annotations
+
+import unittest
+
+from evidence.retrieve import (
+ COBOL_BALANCE_QUERY,
+ COBOL_BALANCE_QUERY_VEC,
+ EvidenceIndex,
+ empty_bundle,
+ lexical_score,
+ retrieve,
+ seed_cobol_corpus,
+)
+
+
+class RetrieveTests(unittest.TestCase):
+ def setUp(self):
+ self.index = EvidenceIndex(seed_cobol_corpus())
+ self.query = COBOL_BALANCE_QUERY
+ self.qvec = COBOL_BALANCE_QUERY_VEC
+
+ def test_cobol_balance_prefers_legacy_not_quant(self):
+ bundle = retrieve(
+ self.query,
+ query_embedding=self.qvec,
+ index=self.index,
+ min_authority=0.5,
+ require_parent_evidence=True,
+ top_k=5,
+ )
+ ids = [s["evidence_id"] for s in bundle["sources"]]
+ self.assertTrue(ids, "expected non-empty sources")
+ # Top hit must not be the quant pricing chunk
+ self.assertNotEqual(ids[0], "ev_quant_pricing_01")
+ # COBOL / copybook / schema evidence should appear
+ self.assertTrue(
+ any(
+ i in ids
+ for i in ("ev_copybook_01", "ev_cobol_src_01", "ev_db2_schema_01")
+ ),
+ f"expected legacy evidence in {ids}",
+ )
+ # Quant must not be the top hit; preferably absent from top-3 when
+ # authority + dense favor legacy chunks
+ top3 = ids[:3]
+ self.assertNotIn("ev_quant_pricing_01", top3)
+
+ def test_provenance_authority_drops_junk(self):
+ # Without filter, junk may score on lexical overlap
+ open_bundle = retrieve(
+ self.query,
+ query_embedding=self.qvec,
+ index=self.index,
+ min_authority=0.0,
+ require_parent_evidence=False,
+ top_k=10,
+ )
+ open_ids = {s["evidence_id"] for s in open_bundle["sources"]}
+ self.assertIn("ev_junk_lowauth_01", open_ids)
+
+ filtered = retrieve(
+ self.query,
+ query_embedding=self.qvec,
+ index=self.index,
+ min_authority=0.5,
+ require_parent_evidence=True,
+ top_k=10,
+ )
+ filt_ids = {s["evidence_id"] for s in filtered["sources"]}
+ self.assertNotIn("ev_junk_lowauth_01", filt_ids)
+ # Every returned source has provenance fields
+ for src in filtered["sources"]:
+ self.assertIn("content_hash", src)
+ self.assertIn("source_authority", src)
+ self.assertIn("parent_evidence", src)
+ self.assertTrue(src["parent_evidence"])
+
+ def test_hybrid_lexical_can_lift_weak_dense(self):
+ """Rule: score = dense + lexical + authority - penalties (no dense floor).
+
+ ev_lexical_lift_01 has weak cosine vs the COBOL query vector but strong
+ token overlap on cobol/payment/balance, so it can appear in results.
+ """
+ # Dense-only ranking of lexical_lift should be poor
+ from evidence.retrieve import cosine_similarity
+
+ lift = self.index._by_id["ev_lexical_lift_01"]
+ dense_lift = cosine_similarity(self.qvec, lift.embedding)
+ dense_copy = cosine_similarity(
+ self.qvec, self.index._by_id["ev_copybook_01"].embedding
+ )
+ self.assertLess(dense_lift, dense_copy)
+ self.assertLess(dense_lift, 0.55) # weak dense
+
+ lex = lexical_score(self.query, lift.content)
+ self.assertGreaterEqual(lex, 0.5) # strong lexical
+
+ bundle = retrieve(
+ self.query,
+ query_embedding=self.qvec,
+ index=self.index,
+ min_authority=0.5,
+ require_parent_evidence=True,
+ top_k=10,
+ )
+ ids = [s["evidence_id"] for s in bundle["sources"]]
+ self.assertIn(
+ "ev_lexical_lift_01",
+ ids,
+ "lexical-strong / dense-weak chunk should appear under hybrid score",
+ )
+ # Confirm retrieval_metadata records both components
+ meta = {
+ m["evidence_id"]: m for m in bundle["retrieval_metadata"]
+ }
+ self.assertIn("ev_lexical_lift_01", meta)
+ self.assertGreaterEqual(meta["ev_lexical_lift_01"]["lexical"], 0.5)
+ self.assertLess(meta["ev_lexical_lift_01"]["dense"], 0.55)
+
+ def test_empty_query_returns_empty_bundle(self):
+ bundle = retrieve("", query_embedding=self.qvec, index=self.index)
+ self.assertEqual(bundle, empty_bundle())
+ self.assertEqual(bundle["sources"], [])
+ self.assertEqual(bundle["facts"], [])
+
+ def test_no_match_returns_empty_bundle(self):
+ # Metadata filter that matches nothing
+ bundle = retrieve(
+ self.query,
+ query_embedding=self.qvec,
+ index=self.index,
+ source_types=["nonexistent_type"],
+ top_k=5,
+ )
+ self.assertEqual(bundle["sources"], [])
+ self.assertEqual(bundle["facts"], [])
+ self.assertEqual(bundle["provenance"], [])
+ self.assertEqual(bundle["retrieval_metadata"], [])
+
+ def test_bundle_shape_has_provenance(self):
+ bundle = retrieve(
+ self.query,
+ query_embedding=self.qvec,
+ index=self.index,
+ min_authority=0.5,
+ top_k=3,
+ )
+ for key in ("facts", "sources", "provenance", "retrieval_metadata"):
+ self.assertIn(key, bundle)
+ self.assertEqual(len(bundle["sources"]), len(bundle["provenance"]))
+ # No bare text: each source carries provenance fields
+ for src in bundle["sources"]:
+ self.assertTrue(src.get("content_hash"))
+ self.assertIsNotNone(src.get("source_authority"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/evidence/write_router_smoke.py b/evidence/write_router_smoke.py
new file mode 100644
index 0000000000000000000000000000000000000000..83828e80274a0e5e52c61a727fd4c0c78daaa141
--- /dev/null
+++ b/evidence/write_router_smoke.py
@@ -0,0 +1,133 @@
+"""Append a router smoke log line using frozen scorer + Spec symbolic.
+
+Does not modify router/scorer.py or router/symbolic.py.
+Scores come only from scorer.score_agent (via symbolic.minimum_agent_set).
+"""
+
+from __future__ import annotations
+
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+# David root on path so `evidence` and `router` imports resolve
+DAVID_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(DAVID_ROOT))
+sys.path.insert(0, str(DAVID_ROOT / "router"))
+
+from scorer import AgentDescriptor, RequestContext, score_agent # noqa: E402
+from symbolic import minimum_agent_set # noqa: E402
+
+from evidence.retrieve import ( # noqa: E402
+ COBOL_BALANCE_QUERY,
+ COBOL_BALANCE_QUERY_VEC,
+ EvidenceIndex,
+ retrieve,
+ seed_cobol_corpus,
+)
+
+LOG_PATH = DAVID_ROOT / "logs" / "router_smoke.log"
+
+
+def _agent(aid, caps, deps=(), control=False, risk="medium", perms=(), inputs=()):
+ """Same helper shape as router/test_scorer.py."""
+ return AgentDescriptor(
+ agent_id=aid,
+ domain="test",
+ capabilities=frozenset(caps),
+ input_types=frozenset(inputs or ("source_code",)),
+ permissions=frozenset(perms or ("read_source",)),
+ risk_class=risk,
+ dependencies=frozenset(deps),
+ historical_success=0.5,
+ control_plane=control,
+ )
+
+
+def cobol_request_context():
+ """COBOL-style RequestContext from router/test_scorer.py."""
+ registry = {
+ "LegacyCobol": _agent(
+ "LegacyCobol",
+ ["cobol", "business_rule_extraction"],
+ deps=("Database", "Provenance"),
+ risk="high",
+ ),
+ "Database": _agent("Database", ["schema_analysis"], inputs=("db_schema",)),
+ "Provenance": _agent("Provenance", ["provenance_dag"]),
+ "Quant": _agent("Quant", ["pricing", "monte_carlo"]),
+ "Risk": _agent("Risk", ["risk_policy"], control=True, risk="critical"),
+ "Response": _agent("Response", ["synthesis"], control=True),
+ }
+ candidates = list(registry.values())
+ req = RequestContext(
+ required_capabilities=frozenset({"cobol", "business_rule_extraction"}),
+ available_inputs=frozenset({"source_code", "copybooks"}),
+ granted_permissions=frozenset({"read_source", "read_copybooks"}),
+ max_risk_class="high",
+ semantic_scores={
+ "LegacyCobol": 0.92,
+ "Database": 0.4,
+ "Quant": 0.1,
+ "Provenance": 0.3,
+ "Risk": 0.2,
+ "Response": 0.2,
+ },
+ )
+ return registry, candidates, req
+
+
+def write_smoke_log(path: Path | None = None) -> Path:
+ log_path = path or LOG_PATH
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+
+ registry, candidates, req = cobol_request_context()
+ # Prefer Spec's minimum_agent_set (uses scorer.score_agent only)
+ selected = minimum_agent_set(candidates, req, registry)
+ # Touch score_agent explicitly so the smoke note is accurate
+ _ = {a.agent_id: score_agent(a, req) for a in candidates}
+
+ bundle = retrieve(
+ COBOL_BALANCE_QUERY,
+ query_embedding=COBOL_BALANCE_QUERY_VEC,
+ index=EvidenceIndex(seed_cobol_corpus()),
+ min_authority=0.5,
+ require_parent_evidence=True,
+ top_k=5,
+ )
+ top_eids = [s["evidence_id"] for s in bundle["sources"]]
+
+ # Timestamp in America/Los_Angeles for operator-facing logs
+ try:
+ from zoneinfo import ZoneInfo
+
+ ts = datetime.now(ZoneInfo("America/Los_Angeles")).strftime(
+ "%Y-%m-%d %H:%M:%S %Z"
+ )
+ except Exception:
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
+
+ quant_absent = "Quant" not in selected
+ risk_present = "Risk" in selected
+ response_present = "Response" in selected
+
+ lines = [
+ f"[{ts}] router_smoke minimum_agent_set selected={selected}",
+ f"[{ts}] Quant absent={quant_absent}; Risk present={risk_present}; "
+ f"Response present={response_present}",
+ f"[{ts}] note: scores came from scorer.score_agent only "
+ f"(via symbolic.minimum_agent_set; no second scorer)",
+ f"[{ts}] evidence_smoke query={COBOL_BALANCE_QUERY!r} "
+ f"top_evidence_ids={top_eids}",
+ "",
+ ]
+
+ with log_path.open("a", encoding="utf-8") as fh:
+ fh.write("\n".join(lines))
+
+ return log_path
+
+
+if __name__ == "__main__":
+ out = write_smoke_log()
+ print(f"appended router smoke log → {out}")
diff --git a/execution/__init__.py b/execution/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..1347f774ddf9daa490986f1beb8689ab5a0da050
--- /dev/null
+++ b/execution/__init__.py
@@ -0,0 +1,20 @@
+"""Sparse agent execution: WorkItem in, AgentResult out, selected set only."""
+
+from .dispatcher import (
+ ExecutionRequest,
+ build_work_items,
+ dispatch,
+ stub_handler,
+)
+from .models import AgentResult, Evidence, EvidenceBundle, WorkItem
+
+__all__ = [
+ "AgentResult",
+ "Evidence",
+ "EvidenceBundle",
+ "ExecutionRequest",
+ "WorkItem",
+ "build_work_items",
+ "dispatch",
+ "stub_handler",
+]
diff --git a/execution/dispatcher.py b/execution/dispatcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..ede7f8ae36bf878287bb1965808cfc60b69e8ae9
--- /dev/null
+++ b/execution/dispatcher.py
@@ -0,0 +1,99 @@
+"""Execute only the sparse-selected agent set — no swarm fan-out."""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass
+
+from .models import AgentResult, EvidenceBundle, WorkItem
+
+# Handler: (WorkItem) -> AgentResult
+AgentHandler = Callable[[WorkItem], AgentResult]
+
+
+@dataclass(frozen=True)
+class ExecutionRequest:
+ request_id: str
+ task: str
+ evidence_bundle: EvidenceBundle
+ required_outputs: tuple[str, ...]
+ permissions: frozenset[str]
+ risk_policy: str
+ deadline: str | None = None
+
+
+def build_work_items(
+ selected_agent_ids: Sequence[str],
+ req: ExecutionRequest,
+) -> list[WorkItem]:
+ """One WorkItem per selected agent_id. Order preserved. No extras."""
+ if not selected_agent_ids:
+ return []
+ # Deduplicate while preserving order (router may close deps + control plane).
+ seen: set[str] = set()
+ ordered: list[str] = []
+ for aid in selected_agent_ids:
+ if aid not in seen:
+ seen.add(aid)
+ ordered.append(aid)
+ return [
+ WorkItem(
+ request_id=req.request_id,
+ work_id=f"{req.request_id}:{aid}:{uuid.uuid4().hex[:8]}",
+ agent_id=aid,
+ task=req.task,
+ evidence_bundle=req.evidence_bundle,
+ required_outputs=req.required_outputs,
+ permissions=req.permissions,
+ deadline=req.deadline,
+ risk_policy=req.risk_policy,
+ )
+ for aid in ordered
+ ]
+
+
+def dispatch(
+ selected_agent_ids: Sequence[str],
+ req: ExecutionRequest,
+ handlers: Mapping[str, AgentHandler],
+ *,
+ allow_missing: bool = False,
+) -> list[AgentResult]:
+ """
+ Run handlers only for selected agents.
+
+ Agents not in selected_agent_ids are never invoked.
+ Missing handlers raise KeyError unless allow_missing (then status=skipped).
+ """
+ items = build_work_items(selected_agent_ids, req)
+ results: list[AgentResult] = []
+ for item in items:
+ handler = handlers.get(item.agent_id)
+ if handler is None:
+ if not allow_missing:
+ raise KeyError(f"no handler for selected agent {item.agent_id!r}")
+ results.append(
+ AgentResult(agent_id=item.agent_id, status="skipped")
+ )
+ continue
+ results.append(handler(item))
+ return results
+
+
+def stub_handler(agent_id: str) -> AgentHandler:
+ """Deterministic stub for wiring tests (not a real agent)."""
+
+ def _run(item: WorkItem) -> AgentResult:
+ assert item.agent_id == agent_id
+ return AgentResult(
+ agent_id=agent_id,
+ conclusions=({"note": f"stub:{agent_id}", "work_id": item.work_id},),
+ evidence_refs=tuple(
+ e.evidence_id for e in item.evidence_bundle.sources
+ ),
+ provenance_root=f"prov:{item.request_id}:{agent_id}",
+ status="ok",
+ )
+
+ return _run
diff --git a/execution/models.py b/execution/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..66795a23491e656c252c60dd0ca6201c653aa965
--- /dev/null
+++ b/execution/models.py
@@ -0,0 +1,66 @@
+"""WorkItem / AgentResult / EvidenceBundle contracts for sparse execution."""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+
+@dataclass(frozen=True)
+class Evidence:
+ evidence_id: str
+ source_id: str
+ source_type: str
+ document_id: str
+ location: str
+ content_hash: str
+ embedding_id: str | None = None
+ retrieved_by: str | None = None
+ retrieval_score: float | None = None
+ source_authority: str | None = None
+ timestamp: str | None = None
+ parent_evidence: tuple[str, ...] = ()
+ content: str | None = None # never delivered without the fields above
+
+
+@dataclass(frozen=True)
+class EvidenceBundle:
+ facts: tuple[dict[str, Any], ...] = ()
+ sources: tuple[Evidence, ...] = ()
+ provenance: tuple[dict[str, Any], ...] = ()
+ retrieval_metadata: tuple[dict[str, Any], ...] = ()
+
+
+@dataclass(frozen=True)
+class WorkItem:
+ request_id: str
+ work_id: str
+ agent_id: str
+ task: str
+ evidence_bundle: EvidenceBundle
+ required_outputs: tuple[str, ...]
+ permissions: frozenset[str]
+ deadline: str | None
+ risk_policy: str
+
+ def to_dict(self) -> dict[str, Any]:
+ d = asdict(self)
+ d["permissions"] = sorted(self.permissions)
+ return d
+
+
+@dataclass(frozen=True)
+class AgentResult:
+ agent_id: str
+ facts: tuple[dict[str, Any], ...] = ()
+ conclusions: tuple[dict[str, Any], ...] = ()
+ assumptions: tuple[str, ...] = ()
+ uncertainty: tuple[str, ...] = ()
+ evidence_refs: tuple[str, ...] = ()
+ transformations: tuple[dict[str, Any], ...] = ()
+ risk_signals: tuple[str, ...] = ()
+ provenance_root: str | None = None
+ status: str = "ok" # ok | failed | escalated | skipped
+
+ def to_dict(self) -> dict[str, Any]:
+ return asdict(self)
diff --git a/execution/test_dispatcher.py b/execution/test_dispatcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..3cec401b1b48e080be4a8b47a7a41598d14b2a7f
--- /dev/null
+++ b/execution/test_dispatcher.py
@@ -0,0 +1,97 @@
+"""Smoke: only selected agents receive WorkItems and run."""
+
+from __future__ import annotations
+
+import unittest
+
+from execution.models import Evidence, EvidenceBundle
+from execution.dispatcher import (
+ ExecutionRequest,
+ build_work_items,
+ dispatch,
+ stub_handler,
+)
+
+
+SELECTED = [
+ "LegacyCobol",
+ "Database",
+ "ReverseEngineering",
+ "Provenance",
+ "Risk",
+ "Response",
+]
+
+
+class TestSparseDispatch(unittest.TestCase):
+ def setUp(self) -> None:
+ self.bundle = EvidenceBundle(
+ sources=(
+ Evidence(
+ evidence_id="e1",
+ source_id="s1",
+ source_type="cobol_source",
+ document_id="pay.cbl",
+ location="lines:100-200",
+ content_hash="abc",
+ content="COMPUTE BALANCE ...",
+ ),
+ ),
+ )
+ self.req = ExecutionRequest(
+ request_id="req-cobol-1",
+ task=(
+ "Explain how this COBOL payment program calculates "
+ "the account balance and determine whether its Java "
+ "replacement preserves the behavior."
+ ),
+ evidence_bundle=self.bundle,
+ required_outputs=("business_rules", "equivalence_result"),
+ permissions=frozenset(
+ {"read_source", "read_copybooks", "read_schema", "read_evidence"}
+ ),
+ risk_policy="escalate_on_high",
+ )
+ self.handlers = {aid: stub_handler(aid) for aid in SELECTED}
+ # Quant must never run even if a handler exists.
+ self.handlers["Quant"] = stub_handler("Quant")
+
+ def test_work_items_only_for_selected(self) -> None:
+ items = build_work_items(SELECTED, self.req)
+ self.assertEqual([i.agent_id for i in items], SELECTED)
+ self.assertTrue(all(i.request_id == "req-cobol-1" for i in items))
+ self.assertNotIn("Quant", [i.agent_id for i in items])
+
+ def test_dispatch_invokes_only_selected(self) -> None:
+ invoked: list[str] = []
+
+ def wrap(aid: str):
+ inner = stub_handler(aid)
+
+ def _h(item):
+ invoked.append(aid)
+ return inner(item)
+
+ return _h
+
+ handlers = {aid: wrap(aid) for aid in self.handlers}
+ results = dispatch(SELECTED, self.req, handlers)
+ self.assertEqual([r.agent_id for r in results], SELECTED)
+ self.assertEqual(invoked, SELECTED)
+ self.assertNotIn("Quant", invoked)
+ self.assertTrue(all(r.status == "ok" for r in results))
+
+ def test_empty_selection_runs_nobody(self) -> None:
+ results = dispatch([], self.req, self.handlers)
+ self.assertEqual(results, [])
+
+ def test_evidence_bundle_not_bare_text(self) -> None:
+ items = build_work_items(["LegacyCobol"], self.req)
+ src = items[0].evidence_bundle.sources[0]
+ self.assertEqual(src.evidence_id, "e1")
+ self.assertEqual(src.content_hash, "abc")
+ self.assertIsNotNone(src.source_id)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/j/README.md b/j/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a2669339a7ff5980bd91a355c50dfee0eb4e3fa8
--- /dev/null
+++ b/j/README.md
@@ -0,0 +1,26 @@
+# David core (J)
+
+Stack lock: **J + PostgreSQL/pgvector + Prolog**.
+
+| Path | Role |
+|------|------|
+| `scorer.ijs` | Deterministic score shape (ported from Python harness) |
+| `eligibility.ijs` | **Product** — J → SWI-Prolog eligibility (candidates + request → minimum agent set) |
+| `run_eligibility_smoke.ijs` | Product smoke for `eligibility.ijs` (ANN fixture + Quant negative) |
+| `symbolic_call.ijs` | Thin delegate → `run_eligibility_smoke.ijs` |
+| `execution.ijs` | WorkItem → AgentResult; Prolog selected-set; EvidenceBundle-only |
+| `run_execution_smoke.ijs` | Smoke for `execution.ijs` |
+| `evidence.ijs` | Hybrid evidence retrieve (product; seed + `ev_retrieve` / `ev_smoke`) |
+| `run_evidence_smoke.ijs` | Smoke for `evidence.ijs` |
+| `fixtures/ann_topk.txt` | Fixture candidate ids (ANN top-K shape + Quant negative) |
+| `../sql/001_agent_and_evidence_indexes.sql` | AGENT / EVIDENCE indexes |
+| `../sql/002_evidence_hybrid_retrieve.sql` | Product hybrid retrieve against `evidence` |
+| `../router/symbolic_rules.pl` | Product Prolog eligibility rules |
+| `../router/registry_facts.pl` | Generated agent facts from `registry/seed_agents.yaml` |
+
+Python under `../router`, `../execution`, `../evidence` is smoke only — not the product.
+
+Foundry J lives separately at `/workspace/foundry-j`.
+
+Handoff: `../scripts/prolog_selected_handoff.sh` writes `../logs/selected_cobol.txt` for `load_selected`.
+Product Spec path: `eligibility.ijs` → `swipl` `run_eligibility/1` (not Python filters).
diff --git a/j/eligibility.ijs b/j/eligibility.ijs
new file mode 100644
index 0000000000000000000000000000000000000000..3a2b2d3a332f8b05676b3fe536ddff0ff8f25a7f
--- /dev/null
+++ b/j/eligibility.ijs
@@ -0,0 +1,93 @@
+NB. SPDX-License-Identifier: AGPL-3.0-only
+NB. Product path: J passes candidates + request into SWI-Prolog eligibility.
+NB. Embeddings propose; Prolog decides. No Python in this path.
+NB. Atom map: registry id → Prolog atom = downcase first letter (LegacyCobol→legacyCobol).
+
+cocurrent 'david'
+
+NB. Repo root relative to j/ working directory
+DAVID_ROOT=: '..'
+
+NB. Registry PascalCase id → Prolog atom (first char only)
+to_prolog_atom=: 3 : 0
+ s=. ,": y
+ if. 0=#s do. '' return. end.
+ u=. a. i. {. s
+ if. (u >: 65) *. (u <: 90) do.
+ ((u + 32) { a.) , }. s
+ else.
+ s
+ end.
+)
+
+NB. Write temporary Prolog request facts (plain clauses; preds are dynamic in rules).
+NB. y = (c), ').')
+ end.
+ for_p. perms do.
+ lines=. lines , <('granted_permission(', req, ', ', (>p), ').')
+ end.
+ lines=. lines , <('max_risk_class(', req, ', ', maxr, ').')
+ for_a. cands do.
+ atom=. to_prolog_atom >a
+ lines=. lines , <('ann_candidate(', req, ', ', atom, ').')
+ end.
+ (; LF ,~ each lines) 1!:2 L
+ if. (0=#s) +. '#' = {. s , ' ' do. continue. end.
+ keep=. keep , (x { y)'
+
+NB. Open one level if boxed
+ev_unbox1 =: 3 : 'if. 32 = 3!:0 y do. > y else. y end.'
+
+NB. --- lowercase + alphanumeric tokens len>1 (lexical stand-in for tsv) -------
+EV_AZ =: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+EV_az =: 'abcdefghijklmnopqrstuvwxyz'
+EV_alnum =: EV_az , '0123456789'
+
+ev_lc1 =: 3 : 0
+ i =. EV_AZ i. y
+ if. i < #EV_AZ do. i { EV_az else. y end.
+)
+
+ev_lc =: 3 : 'ev_lc1"0 y'
+
+ev_tokens =: 3 : 0
+ if. 0 = #y do. 0 $ a: return. end.
+ s =. ev_lc , y
+ ok =. s e. EV_alnum
+ s =. ok } (' ' #~ #s) ,: s
+ parts =. <;._1 ' ' , s
+ parts =. parts #~ 1 < #&> parts
+ ~. parts
+)
+
+NB. lexical overlap = |q ∩ c| / |q| (0 if empty query tokens)
+ev_lexical =: 4 : 0
+ q =. ev_tokens x
+ if. 0 = #q do. 0.0 return. end.
+ c =. ev_tokens y
+ if. 0 = #c do. 0.0 return. end.
+ (# q -. q -. c) % #q
+)
+
+NB. cosine similarity of two numeric lists
+ev_cosine =: 4 : 0
+ a =. , x
+ b =. , y
+ if. (#a) ~: #b do. 0.0 return. end.
+ if. (0 = #a) +. (0 = #b) do. 0.0 return. end.
+ na =. %: +/ *: a
+ nb =. %: +/ *: b
+ if. (0 = na) +. (0 = nb) do. 0.0 return. end.
+ (+/ a * b) % (na * nb)
+)
+
+NB. Axes for dim-8 smoke query embed (prod = vector(1536) via encoder)
+NB. [cobol, balance, payment, schema, pricing, vol, greek, junk]
+EV_AXES =: 'cobol';'balance';'payment';'schema';'pricing';'vol';'greek';'junk'
+
+ev_qembed =: 3 : 0
+ toks =. ev_tokens y
+ NB. synonym: volatility → vol
+ if. (<'volatility') e. toks do. toks =. toks , <'vol' end.
+ v =. 0 #~ #EV_AXES
+ i =. 0
+ while. i < #EV_AXES do.
+ if. (i { EV_AXES) e. toks do. v =. 1 i} v end.
+ i =. i + 1
+ end.
+ NB. fallback canned COBOL qvec when axis hits empty but query non-blank
+ if. (0 = +/ v) *. (0 < #y) do.
+ v =. 0.93 0.91 0.88 0.25 0.05 0 0 0
+ end.
+ v
+)
+
+NB. --- seed corpus (mirrors sql/003 + Python harness columns) -----------------
+NB. row: id ; source_id ; source_type ; document_id ; location ;
+NB. content ; authority ; parent_boxed ; embedding (dim 8 smoke)
+
+ev_mk =: 3 : 0
+ NB. y already a 9-field boxed row
+ y
+)
+
+EV_CORPUS =: 0 $ a:
+
+NB. ev_copybook_01
+EV_CORPUS =: EV_CORPUS , < ('ev_copybook_01' ; 'src_copybook_acct' ; 'copybook' ; 'ACCTREC.cpy' ; '01-ACCOUNT-BALANCE' ; '01 ACCOUNT-RECORD. 05 ACCOUNT-BALANCE PIC S9(9)V99. 05 PAYMENT-AMOUNT PIC S9(7)V99. 05 LAST-PAYMENT-DATE PIC X(8).' ; 0.95 ; (<'ev_provenance_root') ; 0.95 0.90 0.85 0.20 0.05 0 0 0)
+
+NB. ev_cobol_src_01
+EV_CORPUS =: EV_CORPUS , < ('ev_cobol_src_01' ; 'src_cobol_pay' ; 'cobol_source' ; 'PAYROLL.cbl' ; 'PROCEDURE DIVISION / CALC-BALANCE' ; 'COMPUTE ACCOUNT-BALANCE = ACCOUNT-BALANCE - PAYMENT-AMOUNT. IF ACCOUNT-BALANCE < ZERO THEN PERFORM OVERDRAFT-CHECK. MOVE PAYMENT-AMOUNT TO WS-LAST-PAYMENT.' ; 0.90 ; (<'ev_provenance_root') ; 0.92 0.88 0.90 0.15 0.05 0 0 0)
+
+NB. ev_db2_schema_01
+EV_CORPUS =: EV_CORPUS , < ('ev_db2_schema_01' ; 'src_db2_acct' ; 'db2_schema' ; 'ACCT.DDL' ; 'TABLE ACCOUNT_BALANCES' ; 'CREATE TABLE ACCOUNT_BALANCES (ACCT_ID CHAR(12), BALANCE DECIMAL(11,2), LAST_PAYMENT DECIMAL(9,2), UPDATED_TS TIMESTAMP).' ; 0.85 ; (<'ev_provenance_root') ; 0.70 0.80 0.55 0.95 0.05 0 0 0)
+
+NB. ev_quant_pricing_01
+EV_CORPUS =: EV_CORPUS , < ('ev_quant_pricing_01' ; 'src_quant_report' ; 'quant_report' ; 'PRICING_Q3.pdf' ; 'section:monte_carlo' ; 'Monte Carlo pricing of exotic options. Implied volatility surface and greek sensitivities. No COBOL or account balance fields.' ; 0.80 ; (<'ev_provenance_root') ; 0.05 0.05 0.05 0.05 0.95 0.90 0.85 0.10)
+
+NB. ev_lexical_lift_01 — weak dense, strong lexical (no dense floor)
+EV_CORPUS =: EV_CORPUS , < ('ev_lexical_lift_01' ; 'src_glossary' ; 'glossary' ; 'LEGACY-GLOSSARY.txt' ; 'entry:PAYMENT-BALANCE' ; 'cobol payment balance glossary entry: PAYMENT and BALANCE fields on ACCOUNT records in legacy batch.' ; 0.70 ; (<'ev_provenance_root') ; 0.10 0.15 0.10 0.05 0.40 0.35 0.30 0.20)
+
+NB. ev_junk_low_auth — authority ~0.1, no parents
+EV_CORPUS =: EV_CORPUS , < ('ev_junk_low_auth' ; 'src_web_scrape' ; 'web_scrape' ; 'random-blog.html' ; 'body' ; 'Someone said COBOL payment balance might work like a spreadsheet. Unverified forum post.' ; 0.10 ; (0 $ a:) ; 0.60 0.55 0.50 0.10 0.10 0 0 0.9)
+
+NB. --- score one row ----------------------------------------------------------
+NB. score = dense + lexical + authority - parent_penalty
+NB. (no dense floor — lexical can lift weak dense)
+NB. x = query ; qvec ; require_parent y = corpus row
+ev_score_row =: 4 : 0
+ q =. 0 ev_field x
+ qv =. 1 ev_field x
+ reqp =. 2 ev_field x
+ row =. y
+ emb =. 8 ev_field row
+ content =. 5 ev_field row
+ auth =. 6 ev_field row
+ parents =. boxopen 7 ev_field row
+ dense =. qv ev_cosine emb
+ lex =. q ev_lexical content
+ pen =. 0.0
+ if. reqp *. (0 = # parents) do. pen =. 1.0 end.
+ dense + lex + auth - pen
+)
+
+NB. --- retrieve ---------------------------------------------------------------
+NB. y = query ; min_authority ; top_k [; require_parent]
+NB. returns boxed list of (evidence_id ; score) sorted score DESC, id ASC
+ev_retrieve =: 3 : 0
+ args =. y
+ q =. 0 ev_field args
+ min_a =. 1 ev_field args
+ top_k =. 2 ev_field args
+ reqp =. 0
+ if. 3 < # args do. reqp =. 3 ev_field args end.
+ NB. empty / blank query → empty result
+ if. 0 = # q do. 0 $ a: return. end.
+ if. 0 = # ev_tokens q do. 0 $ a: return. end.
+ qv =. ev_qembed q
+ ctx =. q ; qv ; reqp
+ ids =. 0 $ a:
+ scores =. 0 $ 0.0
+ i =. 0
+ while. i < # EV_CORPUS do.
+ row =. ev_unbox1 i { EV_CORPUS
+ auth =. 6 ev_field row
+ parents =. boxopen 7 ev_field row
+ if. auth >: min_a do.
+ if. (-. reqp) +. (0 < # parents) do.
+ sc =. ctx ev_score_row row
+ ids =. ids , < 0 ev_field row
+ scores =. scores , sc
+ end.
+ end.
+ i =. i + 1
+ end.
+ if. 0 = # ids do. 0 $ a: return. end.
+ NB. stable: id asc then score desc → ORDER BY score DESC, evidence_id
+ ix =. /: ids
+ ix =. ix \: ix { scores
+ if. top_k < # ix do. ix =. top_k {. ix end.
+ out =. 0 $ a:
+ j =. 0
+ while. j < # ix do.
+ k =. j { ix
+ out =. out , < ((k { ids) ; k { scores)
+ j =. j + 1
+ end.
+ out
+)
+
+NB. peel nested boxes to open string
+ev_openstr =: 3 : 0
+ v =. y
+ while. 32 = 3!:0 v do. v =. > v end.
+ v
+)
+
+NB. ids only from retrieve result (single-boxed strings)
+ev_ids =: 3 : 0
+ rs =. y
+ out =. 0 $ a:
+ i =. 0
+ while. i < # rs do.
+ out =. out , < ev_openstr 0 ev_field ev_unbox1 i { rs
+ i =. i + 1
+ end.
+ out
+)
+
+NB. --- smoke ------------------------------------------------------------------
+ev_smoke =: 3 : 0
+ q =. 'cobol account payment balance CALC-BALANCE'
+ NB. 1) COBOL-ish ranks copybook/cobol/db2 above quant; quant not #1
+ r1 =. ev_retrieve q ; 0.5 ; 5 ; 1
+ ids1 =. ev_ids r1
+ assert. 0 < # ids1
+ assert. -. (< 'ev_quant_pricing_01') -: 0 { ids1
+ legacy =. 'ev_copybook_01' ; 'ev_cobol_src_01' ; 'ev_db2_schema_01'
+ assert. 0 < # (ids1 -. ids1 -. legacy)
+ top3 =. 3 <. # ids1
+ top3 =. top3 {. ids1
+ assert. -. (< 'ev_quant_pricing_01') e. top3
+ NB. 2) min_authority 0.5 drops junk
+ r2 =. ev_retrieve q ; 0.5 ; 10 ; 0
+ ids2 =. ev_ids r2
+ assert. -. (< 'ev_junk_low_auth') e. ids2
+ r2open =. ev_retrieve q ; 0.0 ; 10 ; 0
+ ids2o =. ev_ids r2open
+ assert. (< 'ev_junk_low_auth') e. ids2o
+ NB. 3) empty query → empty result
+ r3 =. ev_retrieve '' ; 0.0 ; 10
+ assert. 0 = # r3
+ 'ok' ; ids1
+)
diff --git a/j/execution.ijs b/j/execution.ijs
new file mode 100644
index 0000000000000000000000000000000000000000..dc29486d8c9093f0b4ff3c490d1f039caa9ce6cb
--- /dev/null
+++ b/j/execution.ijs
@@ -0,0 +1,203 @@
+
+NB. =============================================================================
+NB. Synthetic David — WorkItem / AgentResult sparse execution (PURE J)
+NB. Locale: david. Selected agents only — no swarm fan-out.
+NB. Product path: Prolog selected-set → dispatch_selected; EvidenceBundle only.
+NB. Python ../execution stays smoke harness only.
+NB. =============================================================================
+
+cocurrent 'david'
+
+NB. Evidence row (boxed fields):
+NB. 0 evidence_id 1 source_id 2 source_type 3 content_hash 4 authority
+NB.
+NB. WorkItem (boxed fields):
+NB. 0 request_id 1 work_id 2 agent_id 3 task
+NB. 4 evidence_rows 5 required_outputs 6 permissions 7 deadline 8 risk_policy
+NB.
+NB. AgentResult (boxed fields):
+NB. 0 agent_id 1 status 2 conclusions 3 evidence_refs 4 provenance_root
+
+openscalar =: 3 : 0
+ v =. y
+ while. (32 = 3!:0 v) *. (0 = # $ v) do. v =. > v end.
+ v
+)
+
+field =: 4 : 'openscalar x { y'
+
+NB. Non-empty character / open content
+nonempty =: 3 : 0
+ v =. openscalar y
+ if. 32 = 3!:0 v do. 0 return. end.
+ 0 < # v
+)
+
+NB. One evidence row is ok iff evidence_id, source_id, content_hash are non-empty.
+NB. Bare text alone is never enough.
+evidence_row_ok =: 3 : 0
+ r =. y
+ if. 3 > # r do. 0 return. end.
+ (nonempty 0 { r) *. (nonempty 1 { r) *. (nonempty 3 { r)
+)
+
+NB. Bundle = list of boxed evidence rows; empty bundle is not ok for dispatch.
+bundle_ok =: 3 : 0
+ rows =. y
+ if. 0 = # rows do. 0 return. end.
+ i =. 0
+ ok =. 1
+ while. i < # rows do.
+ ok =. ok *. evidence_row_ok openscalar i { rows
+ i =. i + 1
+ end.
+ ok
+)
+
+uniqseq =: 3 : 0
+ if. 0 = # y do. 0 $ a: return. end.
+ ys =. boxopen y
+ out =. 0 $ a:
+ i =. 0
+ while. i < # ys do.
+ v =. openscalar i { ys
+ if. -. (< v) e. out do. out =. out , < v end.
+ i =. i + 1
+ end.
+ out
+)
+
+fields =: 4 : 0
+ rows =. y
+ i =. 0
+ out =. 0 $ a:
+ while. i < # rows do.
+ row =. openscalar i { rows
+ out =. out , < x field row
+ i =. i + 1
+ end.
+ out
+)
+
+NB. Load selected agent ids from a text file (one id per line).
+NB. Product handoff from Prolog / Spec J→swipl writer.
+load_selected =: 3 : 0
+ if. 0 = # (1!:0 < y) do. 0 $ a: return. end.
+ raw =. 1!:1 < y
+ lines =. <;._2 raw , LF
+ out =. 0 $ a:
+ i =. 0
+ while. i < # lines do.
+ s =. openscalar i { lines
+ NB. drop CR if present (Windows handoff)
+ if. (2 = 3!:0 s) *. (0 < # s) do.
+ if. 13 = {: s do. s =. }: s end.
+ end.
+ if. (2 = 3!:0 s) *. (0 < # s) do. out =. out , < s end.
+ i =. i + 1
+ end.
+ uniqseq out
+)
+
+NB. Evidence ids from validated bundle rows
+bundle_ids =: 3 : 0
+ rows =. y
+ out =. 0 $ a:
+ i =. 0
+ while. i < # rows do.
+ r =. openscalar i { rows
+ out =. out , < 0 field r
+ i =. i + 1
+ end.
+ out
+)
+
+NB. x = request: request_id ; task ; evidence_bundle ; required_outputs ; permissions ; risk_policy ; deadline
+NB. y = selected agent_id list (from Prolog)
+NB. Fails assert if bundle is not EvidenceBundle-ok (no bare text).
+build_work_items =: 4 : 0
+ bundle =. 2 field x
+ NB. field peels the outer box; bundle is a list of evidence rows
+ if. (32 = 3!:0 bundle) *. (0 = # $ bundle) do. bundle =. , < openscalar bundle end.
+ assert. bundle_ok bundle
+ aids =. uniqseq y
+ rid =. 0 field x
+ task =. 1 field x
+ outs =. boxopen 3 field x
+ perms =. boxopen 4 field x
+ rpol =. 5 field x
+ dl =. 6 field x
+ wis =. 0 $ a:
+ i =. 0
+ while. i < # aids do.
+ aid =. openscalar i { aids
+ wid =. rid , ':' , aid
+ wi =. ( (x { y)'
+
+NB. x = req: caps ; inputs ; perms ; . 1 <. (6 field y)
+ cp =. 7 field y
+ sem =. 8 field y
+ cap =. caps jaccard rcaps
+ inp =. ins jaccard ain
+ dep =. (0 = # boxopen deps) { 0.5 1.0
+ miss =. 0 < # (boxopen perms) -. boxopen gperm
+ rix =. riskix rcls
+ rpen =. rix { RISKPEN
+ if. rix > riskix maxr do. rpen =. rpen + 1 end.
+ unnec =. -. ((cap > 0) +. (cp ~: 0))
+ (+/ WPOS * sem,cap,inp,hist,dep) - +/ WNEG * rpen,miss,unnec
+)
+
+smoke =: 3 : 0
+ NB. ; boxes each item once — do not pre-box scalars
+ req =. ('cobol';'business_rule_extraction') ; ('source_code';'copybooks') ; ('read_source';'read_copybooks') ; 'high'
+ legacy =. 'LegacyCobol' ; ('cobol';'business_rule_extraction') ; (<'source_code') ; (<'read_source') ; 'high' ; ('Database';'Provenance') ; 0.5 ; 0 ; 0.92
+ quant =. 'Quant' ; ('pricing';'monte_carlo') ; (<'source_code') ; (<'read_source') ; 'medium' ; (0$a:) ; 0.5 ; 0 ; 0.1
+ sl =. req score_agent legacy
+ sq =. req score_agent quant
+ assert. sl > sq
+ 'ok' ; sl ; sq
+)
diff --git a/j/symbolic_call.ijs b/j/symbolic_call.ijs
new file mode 100644
index 0000000000000000000000000000000000000000..1dc961cb47c127725a7469bf3dcd5c22d110e540
--- /dev/null
+++ b/j/symbolic_call.ijs
@@ -0,0 +1,7 @@
+NB. Thin J caller for Spec Prolog eligibility.
+NB. Prefer product path: eligibility.ijs + run_eligibility_smoke.ijs
+NB. This file now delegates to the product smoke (requires swipl on PATH).
+NB. Spawn: /home/box/j/j9.7/bin/jconsole symbolic_call.ijs
+
+echo 'symbolic_call.ijs → run_eligibility_smoke.ijs (J+Prolog product path)'
+(0!:0) <'run_eligibility_smoke.ijs'
diff --git a/logs/agent_ann_smoke.log b/logs/agent_ann_smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..2bb9bd966a82f3e63a0a9b8800356a5a8ae73460
--- /dev/null
+++ b/logs/agent_ann_smoke.log
@@ -0,0 +1,10 @@
+query=Explain how this COBOL payment program calculates the account balance and determ...
+top_k=5
+1 LegacyCobol legacy_mainframe high sim=0.406562
+2 ReverseEngineering legacy_mainframe medium sim=0.309016
+3 MigrationValidation migration high sim=0.160817
+4 Modernization migration high sim=0.154508
+5 Response control_plane medium sim=0.000000
+legacy_in_topk=True
+quant_not_preferred=True
+pass=True
diff --git a/logs/evidence_hybrid_smoke.log b/logs/evidence_hybrid_smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..7aaf5f97b5b6e63f91413cf4f9423a70242d1347
--- /dev/null
+++ b/logs/evidence_hybrid_smoke.log
@@ -0,0 +1,14 @@
+query=Explain how this COBOL payment program calculates the account balance and determ...
+lexical_query=payment balance account
+top_k=5 min_authority=0.5 require_parent=False
+rank evidence_id source_type score dense lexical authority
+1 ev_cobol_src_01 cobol_source score=1.589825 dense=0.583714 lexical=0.106111 authority=0.900000
+2 ev_copybook_01 copybook score=1.323832 dense=0.359546 lexical=0.014286 authority=0.950000
+3 ev_lexical_lift_01 glossary score=1.207805 dense=0.482805 lexical=0.025000 authority=0.700000
+4 ev_db2_schema_01 db2_schema score=1.104701 dense=0.243590 lexical=0.011111 authority=0.850000
+5 ev_quant_pricing_01 quant_report score=1.030079 dense=0.230079 lexical=0.000000 authority=0.800000
+junk_exists=True
+junk_dropped=True
+legacy_in_topk=True
+quant_not_preferred=True
+pass=True
diff --git a/logs/j_evidence_smoke.log b/logs/j_evidence_smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..148a052f97764152fc2aa9105f826b84770fd8f5
--- /dev/null
+++ b/logs/j_evidence_smoke.log
@@ -0,0 +1,6 @@
+┌──┬──────────────┬───────────────┬────────────────┬──────────────────┬───────────────────┐
+│ok│ev_copybook_01│ev_cobol_src_01│ev_db2_schema_01│ev_lexical_lift_01│ev_quant_pricing_01│
+└──┴──────────────┴───────────────┴────────────────┴──────────────────┴───────────────────┘
+exit_code=0
+SQL product query: sql/002_evidence_hybrid_retrieve.sql
+Python evidence/ is harness only — do not grow; J evidence.ijs is the product path.
diff --git a/logs/j_execution_smoke.log b/logs/j_execution_smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..6b4c86e283d3182a14c70758499031ba5a009bc2
--- /dev/null
+++ b/logs/j_execution_smoke.log
@@ -0,0 +1,3 @@
+┌──┬─┬─┬─┐
+│ok│6│6│3│
+└──┴─┴─┴─┘
diff --git a/logs/prolog_eligibility_smoke.log b/logs/prolog_eligibility_smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..be83234b0b14170048d3a05bb7ca43bc7fe43dd2
--- /dev/null
+++ b/logs/prolog_eligibility_smoke.log
@@ -0,0 +1,7 @@
+┌───────────┬───────────┬──────────────────┬───────────────────┬─────────────┬────────┬─────┐
+│candidates:│LegacyCobol│ReverseEngineering│MigrationValidation│Modernization│Response│Quant│
+└───────────┴───────────┴──────────────────┴───────────────────┴─────────────┴────────┴─────┘
+┌─────────┬───────────┬────────┬──────────────────┬──────────┬────┬────────┐
+│selected:│legacyCobol│database│reverseEngineering│provenance│risk│response│
+└─────────┴───────────┴────────┴──────────────────┴──────────┴────┴────────┘
+eligibility_smoke: OK
diff --git a/logs/prolog_registry_sync.log b/logs/prolog_registry_sync.log
new file mode 100644
index 0000000000000000000000000000000000000000..f372e37e6c52d3bafacd72302846f908111c3f68
--- /dev/null
+++ b/logs/prolog_registry_sync.log
@@ -0,0 +1,3 @@
+wrote /tmp/registry_facts.rqcYnl.pl (10 agents)
+sync ok: 10 agents; Quant present as negative
+prolog_registry_sync: OK
diff --git a/logs/prolog_selected_cobol.raw b/logs/prolog_selected_cobol.raw
new file mode 100644
index 0000000000000000000000000000000000000000..12bb9ecd733d6190b092acd6b0f27c4aa0a768a8
--- /dev/null
+++ b/logs/prolog_selected_cobol.raw
@@ -0,0 +1,2 @@
+selected:[legacyCobol,database,reverseEngineering,provenance,risk,response]
+smoke_cobol: OK
diff --git a/logs/prolog_smoke_cobol.log b/logs/prolog_smoke_cobol.log
new file mode 100644
index 0000000000000000000000000000000000000000..12bb9ecd733d6190b092acd6b0f27c4aa0a768a8
--- /dev/null
+++ b/logs/prolog_smoke_cobol.log
@@ -0,0 +1,2 @@
+selected:[legacyCobol,database,reverseEngineering,provenance,risk,response]
+smoke_cobol: OK
diff --git a/logs/router_smoke.log b/logs/router_smoke.log
new file mode 100644
index 0000000000000000000000000000000000000000..9ef525c4b4694c975512b2442299d962db752e1f
--- /dev/null
+++ b/logs/router_smoke.log
@@ -0,0 +1,4 @@
+[2026-10-07 06:14:51 PDT] router_smoke minimum_agent_set selected=['LegacyCobol', 'Database', 'Provenance', 'Risk', 'Response']
+[2026-10-07 06:14:51 PDT] Quant absent=True; Risk present=True; Response present=True
+[2026-10-07 06:14:51 PDT] note: scores came from scorer.score_agent only (via symbolic.minimum_agent_set; no second scorer)
+[2026-10-07 06:14:51 PDT] evidence_smoke query='cobol account payment balance CALC-BALANCE' top_evidence_ids=['ev_copybook_01', 'ev_cobol_src_01', 'ev_db2_schema_01', 'ev_lexical_lift_01', 'ev_quant_pricing_01']
diff --git a/logs/selected_cobol.txt b/logs/selected_cobol.txt
new file mode 100644
index 0000000000000000000000000000000000000000..9b4a25c54a5b3cdb1881516bbf8e5cbe1ff6b737
--- /dev/null
+++ b/logs/selected_cobol.txt
@@ -0,0 +1,6 @@
+legacyCobol
+database
+reverseEngineering
+provenance
+risk
+response
diff --git a/registry/agent_descriptor.schema.json b/registry/agent_descriptor.schema.json
new file mode 100644
index 0000000000000000000000000000000000000000..4f427eee327ff00088c3a46d776368e3c8ab25b5
--- /dev/null
+++ b/registry/agent_descriptor.schema.json
@@ -0,0 +1,69 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://david.local/schemas/agent_descriptor.json",
+ "title": "AgentDescriptor",
+ "type": "object",
+ "required": [
+ "agent_id",
+ "domain",
+ "capabilities",
+ "languages",
+ "input_types",
+ "output_types",
+ "required_evidence",
+ "permissions",
+ "risk_class",
+ "dependencies",
+ "execution_endpoint"
+ ],
+ "properties": {
+ "agent_id": { "type": "string", "minLength": 1 },
+ "domain": { "type": "string" },
+ "capabilities": {
+ "type": "array",
+ "items": { "type": "string" },
+ "uniqueItems": true
+ },
+ "languages": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "input_types": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "output_types": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "required_evidence": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "permissions": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "risk_class": {
+ "type": "string",
+ "enum": ["low", "medium", "high", "critical"]
+ },
+ "dependencies": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Other agent_id values required for dependency closure"
+ },
+ "embedding": {
+ "type": "array",
+ "items": { "type": "number" },
+ "description": "Optional; usually stored only in pgvector"
+ },
+ "execution_endpoint": { "type": "string" },
+ "control_plane": {
+ "type": "boolean",
+ "default": false,
+ "description": "If true, always considered for Risk/Audit/Response closure"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/registry/seed_agents.yaml b/registry/seed_agents.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..4b7a0221230d39395ddd4e73840a0139a0a9e88c
--- /dev/null
+++ b/registry/seed_agents.yaml
@@ -0,0 +1,132 @@
+# Seed routable agents (not permanently active processes)
+
+- agent_id: LegacyCobol
+ domain: legacy_mainframe
+ capabilities:
+ - cobol
+ - copybook_analysis
+ - control_flow
+ - data_flow
+ - business_rule_extraction
+ - modernization_analysis
+ languages: [cobol]
+ input_types: [source_code, copybooks, compiler_metadata]
+ output_types: [call_graph, business_rules, data_flow]
+ required_evidence: [source_code, copybooks, compiler_metadata]
+ permissions: [read_source, read_copybooks]
+ risk_class: high
+ dependencies: [Database, ReverseEngineering, Provenance]
+ execution_endpoint: agent://LegacyCobol
+ control_plane: false
+
+- agent_id: Database
+ domain: data
+ capabilities: [schema_analysis, db2, sql]
+ languages: [sql]
+ input_types: [db_schema, queries]
+ output_types: [schema_map, data_lineage]
+ required_evidence: [db_schema]
+ permissions: [read_schema]
+ risk_class: medium
+ dependencies: []
+ execution_endpoint: agent://Database
+
+- agent_id: ReverseEngineering
+ domain: legacy_mainframe
+ capabilities: [call_graph, control_flow, data_flow]
+ languages: [cobol, java]
+ input_types: [source_code]
+ output_types: [call_graph, cfg]
+ required_evidence: [source_code]
+ permissions: [read_source]
+ risk_class: medium
+ dependencies: []
+ execution_endpoint: agent://ReverseEngineering
+
+- agent_id: Modernization
+ domain: migration
+ capabilities: [cobol_to_java, api_mapping]
+ languages: [cobol, java]
+ input_types: [business_rules, call_graph]
+ output_types: [migration_plan, java_skeleton]
+ required_evidence: [business_rules]
+ permissions: [read_source, write_plan]
+ risk_class: high
+ dependencies: [LegacyCobol, Provenance]
+ execution_endpoint: agent://Modernization
+
+- agent_id: MigrationValidation
+ domain: migration
+ capabilities: [equivalence, behavioral_compare]
+ languages: [cobol, java]
+ input_types: [legacy_output, java_output]
+ output_types: [equivalence_result]
+ required_evidence: [legacy_output, java_output]
+ permissions: [read_outputs]
+ risk_class: high
+ dependencies: [Provenance, LegacyCobol]
+ execution_endpoint: agent://MigrationValidation
+
+- agent_id: Provenance
+ domain: evidence
+ capabilities: [provenance_dag, content_hash, authority]
+ languages: []
+ input_types: [evidence, facts]
+ output_types: [provenance_root, evidence_graph]
+ required_evidence: []
+ permissions: [read_evidence, write_provenance]
+ risk_class: medium
+ dependencies: []
+ execution_endpoint: agent://Provenance
+
+- agent_id: Risk
+ domain: control_plane
+ capabilities: [risk_policy, escalation]
+ languages: []
+ input_types: [agent_results, risk_signals]
+ output_types: [risk_decision]
+ required_evidence: []
+ permissions: [read_all_results]
+ risk_class: critical
+ dependencies: []
+ execution_endpoint: agent://Risk
+ control_plane: true
+
+- agent_id: Audit
+ domain: control_plane
+ capabilities: [audit_trail, compliance]
+ languages: []
+ input_types: [provenance_root, agent_results]
+ output_types: [audit_record]
+ required_evidence: []
+ permissions: [read_all_results]
+ risk_class: high
+ dependencies: [Provenance]
+ execution_endpoint: agent://Audit
+ control_plane: true
+
+- agent_id: Response
+ domain: control_plane
+ capabilities: [synthesis, user_response]
+ languages: []
+ input_types: [conclusions, evidence_bundle]
+ output_types: [response]
+ required_evidence: []
+ permissions: [read_conclusions]
+ risk_class: medium
+ dependencies: []
+ execution_endpoint: agent://Response
+ control_plane: true
+
+- agent_id: Quant
+ domain: markets
+ capabilities: [pricing, monte_carlo, greeks]
+ languages: [python]
+ input_types: [market_data, positions]
+ output_types: [price, risk_metrics]
+ required_evidence: [market_data]
+ permissions: [read_market_data]
+ risk_class: medium
+ dependencies: []
+ execution_endpoint: agent://Quant
+ control_plane: false
diff --git a/router/registry_facts.pl b/router/registry_facts.pl
new file mode 100644
index 0000000000000000000000000000000000000000..722f083297502d4ce80f5996d61071e523e2418c
--- /dev/null
+++ b/router/registry_facts.pl
@@ -0,0 +1,98 @@
+% SPDX-License-Identifier: AGPL-3.0-only
+% Copyright (C) 2026 David contributors
+% GENERATED by scripts/gen_registry_facts.py from registry/seed_agents.yaml
+% Do not edit by hand — regenerate via scripts/check_prolog_registry_sync.sh
+%
+% Agent atom map: registry agent_id → Prolog atom = downcase first letter
+% LegacyCobol → legacyCobol, Risk → risk, Quant → quant
+% Predicates: agent_id/2, agent/1, control_plane/1, capability/2,
+% permission/2, risk_class/2, depends/2
+
+agent_id('LegacyCobol', legacyCobol).
+agent_id('Database', database).
+agent_id('ReverseEngineering', reverseEngineering).
+agent_id('Modernization', modernization).
+agent_id('MigrationValidation', migrationValidation).
+agent_id('Provenance', provenance).
+agent_id('Risk', risk).
+agent_id('Audit', audit).
+agent_id('Response', response).
+agent_id('Quant', quant).
+
+agent(legacyCobol).
+agent(database).
+agent(reverseEngineering).
+agent(modernization).
+agent(migrationValidation).
+agent(provenance).
+agent(risk).
+agent(audit).
+agent(response).
+agent(quant).
+
+control_plane(risk).
+control_plane(audit).
+control_plane(response).
+
+capability(legacyCobol, cobol).
+capability(legacyCobol, copybook_analysis).
+capability(legacyCobol, control_flow).
+capability(legacyCobol, data_flow).
+capability(legacyCobol, business_rule_extraction).
+capability(legacyCobol, modernization_analysis).
+capability(database, schema_analysis).
+capability(database, db2).
+capability(database, sql).
+capability(reverseEngineering, call_graph).
+capability(reverseEngineering, control_flow).
+capability(reverseEngineering, data_flow).
+capability(modernization, cobol_to_java).
+capability(modernization, api_mapping).
+capability(migrationValidation, equivalence).
+capability(migrationValidation, behavioral_compare).
+capability(provenance, provenance_dag).
+capability(provenance, content_hash).
+capability(provenance, authority).
+capability(risk, risk_policy).
+capability(risk, escalation).
+capability(audit, audit_trail).
+capability(audit, compliance).
+capability(response, synthesis).
+capability(response, user_response).
+capability(quant, pricing).
+capability(quant, monte_carlo).
+capability(quant, greeks).
+
+permission(legacyCobol, read_source).
+permission(legacyCobol, read_copybooks).
+permission(database, read_schema).
+permission(reverseEngineering, read_source).
+permission(modernization, read_source).
+permission(modernization, write_plan).
+permission(migrationValidation, read_outputs).
+permission(provenance, read_evidence).
+permission(provenance, write_provenance).
+permission(risk, read_all_results).
+permission(audit, read_all_results).
+permission(response, read_conclusions).
+permission(quant, read_market_data).
+
+risk_class(legacyCobol, high).
+risk_class(database, medium).
+risk_class(reverseEngineering, medium).
+risk_class(modernization, high).
+risk_class(migrationValidation, high).
+risk_class(provenance, medium).
+risk_class(risk, critical).
+risk_class(audit, high).
+risk_class(response, medium).
+risk_class(quant, medium).
+
+depends(legacyCobol, database).
+depends(legacyCobol, reverseEngineering).
+depends(legacyCobol, provenance).
+depends(modernization, legacyCobol).
+depends(modernization, provenance).
+depends(migrationValidation, provenance).
+depends(migrationValidation, legacyCobol).
+depends(audit, provenance).
diff --git a/router/scorer.py b/router/scorer.py
new file mode 100644
index 0000000000000000000000000000000000000000..db979de1f41f36a7fc4d1e5b513b368bd2ef6cb9
--- /dev/null
+++ b/router/scorer.py
@@ -0,0 +1,147 @@
+"""Deterministic sparse-MoA agent scoring (no LLM in the selection loop)."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Iterable
+
+
+WEIGHTS = {
+ "semantic_similarity": 1.0,
+ "capability_match": 1.2,
+ "input_match": 1.0,
+ "historical_success": 0.8,
+ "dependency_coverage": 0.6,
+ "risk_penalty": 0.9,
+ "permission_penalty": 1.5,
+ "unnecessary_agent_penalty": 0.7,
+}
+
+RISK_PENALTY = {"low": 0.0, "medium": 0.15, "high": 0.35, "critical": 0.55}
+
+
+@dataclass(frozen=True)
+class AgentDescriptor:
+ agent_id: str
+ domain: str
+ capabilities: frozenset[str]
+ input_types: frozenset[str]
+ permissions: frozenset[str]
+ risk_class: str
+ dependencies: frozenset[str]
+ historical_success: float = 0.0
+ control_plane: bool = False
+
+
+@dataclass(frozen=True)
+class RequestContext:
+ required_capabilities: frozenset[str]
+ available_inputs: frozenset[str]
+ granted_permissions: frozenset[str]
+ max_risk_class: str = "high"
+ semantic_scores: dict[str, float] = field(default_factory=dict)
+
+
+_RISK_ORDER = ("low", "medium", "high", "critical")
+
+
+def _jaccard(a: frozenset[str], b: frozenset[str]) -> float:
+ if not a and not b:
+ return 1.0
+ if not a or not b:
+ return 0.0
+ return len(a & b) / len(a | b)
+
+
+def risk_exceeds(agent_risk: str, max_risk: str) -> bool:
+ """True if agent_risk is strictly above max_risk (low < medium < high < critical)."""
+ return _RISK_ORDER.index(agent_risk) > _RISK_ORDER.index(max_risk)
+
+
+def _risk_exceeds(agent_risk: str, max_risk: str) -> bool:
+ return risk_exceeds(agent_risk, max_risk)
+
+
+def score_agent(agent: AgentDescriptor, req: RequestContext) -> float:
+ """score = Σ positive terms − penalties. Higher is better."""
+ sem = req.semantic_scores.get(agent.agent_id, 0.0)
+ cap = _jaccard(agent.capabilities, req.required_capabilities)
+ inp = _jaccard(agent.input_types, req.available_inputs)
+ hist = max(0.0, min(1.0, agent.historical_success))
+ # Dependency coverage: fraction of deps present in semantic top pool (caller may refine).
+ dep_cov = 1.0 if not agent.dependencies else 0.5
+
+ missing_perms = agent.permissions - req.granted_permissions
+ perm_pen = 1.0 if missing_perms else 0.0
+ risk_pen = RISK_PENALTY.get(agent.risk_class, 0.5)
+ if _risk_exceeds(agent.risk_class, req.max_risk_class):
+ risk_pen += 1.0
+
+ # Mild penalty if the agent shares no required capabilities (unnecessary).
+ unnecessary = 0.0 if cap > 0.0 or agent.control_plane else 1.0
+
+ return (
+ WEIGHTS["semantic_similarity"] * sem
+ + WEIGHTS["capability_match"] * cap
+ + WEIGHTS["input_match"] * inp
+ + WEIGHTS["historical_success"] * hist
+ + WEIGHTS["dependency_coverage"] * dep_cov
+ - WEIGHTS["risk_penalty"] * risk_pen
+ - WEIGHTS["permission_penalty"] * perm_pen
+ - WEIGHTS["unnecessary_agent_penalty"] * unnecessary
+ )
+
+
+def dependency_closure(
+ selected: Iterable[str],
+ registry: dict[str, AgentDescriptor],
+) -> list[str]:
+ """Expand selected agents with required dependencies (deterministic BFS)."""
+ out: list[str] = []
+ seen: set[str] = set()
+ queue = list(selected)
+ while queue:
+ aid = queue.pop(0)
+ if aid in seen:
+ continue
+ seen.add(aid)
+ out.append(aid)
+ agent = registry.get(aid)
+ if agent is None:
+ continue
+ for dep in sorted(agent.dependencies):
+ if dep not in seen:
+ queue.append(dep)
+ return out
+
+
+def select_minimum_set(
+ candidates: list[AgentDescriptor],
+ req: RequestContext,
+ registry: dict[str, AgentDescriptor],
+ top_k: int = 20,
+ include_control_plane: bool = True,
+) -> list[str]:
+ """
+ Pipeline: score → sort deterministically → top-K → dependency closure
+ → optional control-plane agents → return agent_ids.
+ """
+ ranked = sorted(
+ candidates,
+ key=lambda a: (-score_agent(a, req), a.agent_id),
+ )[:top_k]
+
+ # Keep agents that match at least one required capability (or control plane later).
+ core = [
+ a.agent_id
+ for a in ranked
+ if a.capabilities & req.required_capabilities
+ ]
+ closed = dependency_closure(core, registry)
+
+ if include_control_plane:
+ for a in candidates:
+ if a.control_plane and a.agent_id not in closed:
+ closed.append(a.agent_id)
+
+ return closed
diff --git a/router/symbolic.py b/router/symbolic.py
new file mode 100644
index 0000000000000000000000000000000000000000..b1c2ee0f8d83bf8307ba1dd086bb349bdbf2b4f1
--- /dev/null
+++ b/router/symbolic.py
@@ -0,0 +1,153 @@
+"""Symbolic hard filters and minimum-agent constraint set (Spec / Hilbert).
+
+Hard filters are predicates (True/False), not soft score penalties.
+Scorer remains source of truth for scores — import score_agent / dependency_closure;
+never redefine WEIGHTS or score_agent.
+"""
+
+from __future__ import annotations
+
+from typing import Iterable, Sequence
+
+from scorer import (
+ AgentDescriptor,
+ RequestContext,
+ dependency_closure,
+ risk_exceeds,
+ score_agent,
+)
+
+
+def capability_eligible(agent: AgentDescriptor, req: RequestContext) -> bool:
+ """Agent shares at least one required capability, or is control-plane."""
+ if agent.control_plane:
+ return True
+ return bool(agent.capabilities & req.required_capabilities)
+
+
+def permission_eligible(agent: AgentDescriptor, req: RequestContext) -> bool:
+ """Agent permissions must be a subset of granted permissions."""
+ return agent.permissions <= req.granted_permissions
+
+
+def risk_eligible(agent: AgentDescriptor, req: RequestContext) -> bool:
+ """Agent risk_class must not exceed req.max_risk_class."""
+ return not risk_exceeds(agent.risk_class, req.max_risk_class)
+
+
+def apply_symbolic_filters(
+ candidates: Iterable[AgentDescriptor],
+ req: RequestContext,
+) -> list[AgentDescriptor]:
+ """Return agents that pass capability, permission, and risk hard filters."""
+ return [
+ a
+ for a in candidates
+ if capability_eligible(a, req)
+ and permission_eligible(a, req)
+ and risk_eligible(a, req)
+ ]
+
+
+def _greedy_cover(
+ eligible: Sequence[AgentDescriptor],
+ req: RequestContext,
+) -> list[str]:
+ """Greedy set-cover over required_capabilities (deterministic)."""
+ uncovered = set(req.required_capabilities)
+ remaining = list(eligible)
+ picked: list[str] = []
+ scores = {a.agent_id: score_agent(a, req) for a in remaining}
+
+ while uncovered and remaining:
+ # Primary: most still-uncovered caps; secondary: highest score; tertiary: agent_id
+ def key(a: AgentDescriptor) -> tuple:
+ cover = len(a.capabilities & uncovered)
+ return (-cover, -scores[a.agent_id], a.agent_id)
+
+ best = min(remaining, key=key)
+ cover = len(best.capabilities & uncovered)
+ if cover == 0:
+ break
+ picked.append(best.agent_id)
+ uncovered -= best.capabilities
+ remaining = [a for a in remaining if a.agent_id != best.agent_id]
+
+ return picked
+
+
+def _closure_passes_hard_filters(
+ agent_id: str,
+ req: RequestContext,
+ registry: dict[str, AgentDescriptor],
+) -> bool:
+ """True iff agent_id and all transitive deps pass permission + risk.
+
+ capability_eligible is waived for pure dependency agents pulled in solely
+ as deps (they need not intersect required_capabilities).
+ """
+ for cid in dependency_closure([agent_id], registry):
+ agent = registry.get(cid)
+ if agent is None:
+ return False
+ if not permission_eligible(agent, req):
+ return False
+ if not risk_eligible(agent, req):
+ return False
+ return True
+
+
+def minimum_agent_set(
+ candidates: list[AgentDescriptor],
+ req: RequestContext,
+ registry: dict[str, AgentDescriptor],
+ top_k: int = 20,
+ mandatory_control_plane: Sequence[str] = ("Risk", "Response"),
+) -> list[str]:
+ """
+ Hard-filter + greedy cover + dependency closure + mandatory control plane.
+
+ Order: greedy picks, then closure BFS order, then mandatory control plane
+ in the given order. Unique and stable.
+
+ After closure, every agent must pass permission_eligible and risk_eligible.
+ capability_eligible is waived for pure dependency agents. If a dependency
+ fails those hard filters, its parent is ineligible — that parent and its
+ incomplete closure are dropped.
+
+ Mandatory control-plane agents skip the risk filter (matching scorer
+ include_control_plane) but still require permission_eligible.
+ """
+ # 1–2. Score, sort deterministically, take top_k
+ ranked = sorted(
+ candidates,
+ key=lambda a: (-score_agent(a, req), a.agent_id),
+ )[:top_k]
+
+ # 3. Hard filters on the pool
+ eligible = apply_symbolic_filters(ranked, req)
+
+ # 4. Greedy set-cover
+ greedy_ids = _greedy_cover(eligible, req)
+
+ # 5–6. Closure per pick; drop parents whose closure fails perm/risk
+ kept: list[str] = []
+ for aid in greedy_ids:
+ if _closure_passes_hard_filters(aid, req, registry):
+ kept.append(aid)
+
+ # Closure BFS over surviving greedy picks (full registry)
+ result = dependency_closure(kept, registry)
+
+ # 7. Mandatory control plane: permission only (skip risk), like include_control_plane
+ for mid in mandatory_control_plane:
+ if mid in result:
+ continue
+ agent = registry.get(mid)
+ if agent is None:
+ continue
+ if permission_eligible(agent, req):
+ result.append(mid)
+
+ # 8. Already unique via dependency_closure + append checks
+ return result
diff --git a/router/symbolic_rules.pl b/router/symbolic_rules.pl
new file mode 100644
index 0000000000000000000000000000000000000000..cc95d036e11464dfbb71585cbc1e8093f5144095
--- /dev/null
+++ b/router/symbolic_rules.pl
@@ -0,0 +1,203 @@
+% SPDX-License-Identifier: AGPL-3.0-only
+% Copyright (C) 2026 David contributors
+% Executable eligibility rules for Synthetic David sparse router.
+% Authoritative symbolic layer: embeddings propose; these rules decide.
+% Agent facts live in registry_facts.pl (generated from seed_agents.yaml).
+% Atom map: registry agent_id → Prolog atom via downcase-first-letter
+% (see agent_id/2 in registry_facts.pl). Quant is a negative agent.
+% Run: swipl -q -s symbolic_rules.pl -g smoke_cobol -t halt
+% Product path: J writes request + ann_candidate facts, calls run_eligibility/1.
+
+:- style_check(-singleton).
+:- include('registry_facts.pl').
+
+% ---- risk order ----
+risk_rank(low, 0).
+risk_rank(medium, 1).
+risk_rank(high, 2).
+risk_rank(critical, 3).
+
+risk_exceeds(AgentRisk, MaxRisk) :-
+ risk_rank(AgentRisk, A),
+ risk_rank(MaxRisk, M),
+ A > M.
+
+% ---- request context (dynamic; J / smoke asserts) ----
+% required_capability(Req, Cap).
+% granted_permission(Req, Perm).
+% max_risk_class(Req, Class).
+% ann_candidate(Req, AgentAtom). % optional; when present, core pick restricted
+
+:- dynamic required_capability/2, granted_permission/2, max_risk_class/2.
+:- dynamic ann_candidate/2.
+
+% ---- hard filters ----
+capability_eligible(Agent, Req) :-
+ control_plane(Agent),
+ agent(Agent).
+capability_eligible(Agent, Req) :-
+ agent(Agent),
+ capability(Agent, Cap),
+ required_capability(Req, Cap).
+
+permission_eligible(Agent, Req) :-
+ agent(Agent),
+ forall(permission(Agent, P), granted_permission(Req, P)).
+
+risk_eligible(Agent, Req) :-
+ agent(Agent),
+ risk_class(Agent, AR),
+ max_risk_class(Req, MR),
+ \+ risk_exceeds(AR, MR).
+
+% Core eligible: all three hard filters (control-plane still needs risk unless mandatory).
+eligible(Agent, Req) :-
+ capability_eligible(Agent, Req),
+ permission_eligible(Agent, Req),
+ risk_eligible(Agent, Req).
+
+% Dependency agents may skip capability intersection; still need permission + risk.
+dep_eligible(Agent, Req) :-
+ agent(Agent),
+ permission_eligible(Agent, Req),
+ risk_eligible(Agent, Req).
+
+% Mandatory control plane: permission only (matches scaffold; Risk may be critical).
+mandatory_control_eligible(Agent, Req) :-
+ control_plane(Agent),
+ permission_eligible(Agent, Req).
+
+% ---- dependency closure (BFS via setof/findall) ----
+closure(Seed, Closed) :-
+ closure_(Seed, [], Closed).
+
+closure_([], Acc, Closed) :-
+ reverse(Acc, Closed).
+closure_([H|T], Acc, Closed) :-
+ memberchk(H, Acc), !,
+ closure_(T, Acc, Closed).
+closure_([H|T], Acc, Closed) :-
+ findall(D, depends(H, D), Deps),
+ append(T, Deps, Queue),
+ closure_(Queue, [H|Acc], Closed).
+
+% ---- minimum agent set ----
+% 1. Collect agents that cover at least one required capability and pass hard filters
+% (if ann_candidate/2 facts exist for Req, restrict core seeds to those candidates)
+% 2. Greedy cover of required capabilities (order by agent atom for determinism)
+% 3. Dependency closure; drop incomplete parents if a dep fails dep_eligible
+% 4. Append mandatory Risk + Response if permitted
+% Quant has no overlapping caps for COBOL requests → dropped.
+
+covers_required(Agent, Req, Cap) :-
+ required_capability(Req, Cap),
+ capability(Agent, Cap).
+
+% When ANN candidates are asserted for Req, only those may be core seeds.
+% Deps and mandatory control-plane agents are still closed in below.
+in_ann_or_unrestricted(Agent, Req) :-
+ ( ann_candidate(Req, _) -> ann_candidate(Req, Agent) ; true ).
+
+candidate_core(Agent, Req) :-
+ eligible(Agent, Req),
+ \+ control_plane(Agent),
+ covers_required(Agent, Req, _),
+ in_ann_or_unrestricted(Agent, Req).
+
+greedy_cover(Req, Selected) :-
+ findall(Cap, required_capability(Req, Cap), Caps0),
+ sort(Caps0, Caps),
+ findall(A, candidate_core(A, Req), Cands0),
+ sort(Cands0, Cands),
+ greedy_cover_(Caps, Cands, Req, [], Selected).
+
+greedy_cover_([], _, _, Acc, Selected) :-
+ reverse(Acc, Selected).
+greedy_cover_(Uncovered, Cands, Req, Acc, Selected) :-
+ Uncovered \= [],
+ best_cover(Uncovered, Cands, Acc, Best, CoverN),
+ CoverN > 0, !,
+ findall(C, (member(C, Uncovered), \+ capability(Best, C)), Rest),
+ greedy_cover_(Rest, Cands, Req, [Best|Acc], Selected).
+greedy_cover_(_, _, _, Acc, Selected) :-
+ reverse(Acc, Selected).
+
+best_cover(Uncovered, Cands, Already, Best, CoverN) :-
+ findall(N-A, (
+ member(A, Cands),
+ \+ memberchk(A, Already),
+ findall(C, (member(C, Uncovered), capability(A, C)), Cs),
+ length(Cs, N),
+ N > 0
+ ), Pairs0),
+ sort(0, @>=, Pairs0, Sorted),
+ Sorted = [CoverN-Best|_].
+
+filter_closure([], _, []).
+filter_closure([A|As], Req, [A|Bs]) :-
+ ( memberchk(A, As) -> fail ; true ),
+ ( candidate_core(A, Req) -> true
+ ; depends_from_selected(A, [A|As]) -> dep_eligible(A, Req)
+ ; dep_eligible(A, Req)
+ ), !,
+ filter_closure(As, Req, Bs).
+filter_closure([_|As], Req, Bs) :-
+ filter_closure(As, Req, Bs).
+
+closed_ok(Closed, Req, Seed) :-
+ forall(member(A, Seed), candidate_core(A, Req)),
+ forall(member(A, Closed), dep_eligible(A, Req)).
+
+minimum_agent_set(Req, Final) :-
+ greedy_cover(Req, Seed),
+ closure(Seed, Closed0),
+ include(dep_ok(Req), Closed0, Closed),
+ include(deps_satisfied(Closed), Closed, CoreKept),
+ mandatory_control(Req, Mand),
+ append(CoreKept, Mand, Raw),
+ list_to_set(Raw, Final).
+
+dep_ok(Req, Agent) :- dep_eligible(Agent, Req).
+
+deps_satisfied(Closed, Agent) :-
+ forall(depends(Agent, D), memberchk(D, Closed)).
+
+mandatory_control(Req, Mand) :-
+ findall(A, (
+ member(A, [risk, response]),
+ mandatory_control_eligible(A, Req)
+ ), Mand).
+
+% ---- product entry: print selected atoms (J parses this line) ----
+run_eligibility(Req) :-
+ minimum_agent_set(Req, Selected),
+ format('selected:~w~n', [Selected]).
+
+% ---- COBOL smoke request (no ANN candidates → full registry core) ----
+assert_cobol_req :-
+ retractall(required_capability(cobol_req, _)),
+ retractall(granted_permission(cobol_req, _)),
+ retractall(max_risk_class(cobol_req, _)),
+ retractall(ann_candidate(cobol_req, _)),
+ assertz(required_capability(cobol_req, cobol)),
+ assertz(required_capability(cobol_req, business_rule_extraction)),
+ assertz(granted_permission(cobol_req, read_source)),
+ assertz(granted_permission(cobol_req, read_copybooks)),
+ assertz(granted_permission(cobol_req, read_schema)),
+ assertz(granted_permission(cobol_req, read_evidence)),
+ assertz(granted_permission(cobol_req, write_provenance)),
+ assertz(granted_permission(cobol_req, read_all_results)),
+ assertz(granted_permission(cobol_req, read_conclusions)),
+ assertz(max_risk_class(cobol_req, high)).
+
+smoke_cobol :-
+ assert_cobol_req,
+ minimum_agent_set(cobol_req, Selected),
+ format('selected:~w~n', [Selected]),
+ ( memberchk(legacyCobol, Selected) -> true ; format('FAIL missing LegacyCobol~n'), halt(1) ),
+ ( memberchk(database, Selected) -> true ; format('FAIL missing Database~n'), halt(1) ),
+ ( memberchk(provenance, Selected) -> true ; format('FAIL missing Provenance~n'), halt(1) ),
+ ( memberchk(quant, Selected) -> format('FAIL Quant present~n'), halt(1) ; true ),
+ ( memberchk(risk, Selected) -> true ; format('FAIL missing Risk~n'), halt(1) ),
+ ( memberchk(response, Selected) -> true ; format('FAIL missing Response~n'), halt(1) ),
+ format('smoke_cobol: OK~n').
diff --git a/router/test_scorer.py b/router/test_scorer.py
new file mode 100644
index 0000000000000000000000000000000000000000..16238fbe81363c002185dfddd8c63b82710b7d0b
--- /dev/null
+++ b/router/test_scorer.py
@@ -0,0 +1,71 @@
+"""Smoke tests for deterministic sparse selection."""
+
+from __future__ import annotations
+
+import unittest
+
+from scorer import AgentDescriptor, RequestContext, score_agent, select_minimum_set
+
+
+def _agent(aid, caps, deps=(), control=False, risk="medium", perms=(), inputs=()):
+ return AgentDescriptor(
+ agent_id=aid,
+ domain="test",
+ capabilities=frozenset(caps),
+ input_types=frozenset(inputs or ("source_code",)),
+ permissions=frozenset(perms or ("read_source",)),
+ risk_class=risk,
+ dependencies=frozenset(deps),
+ historical_success=0.5,
+ control_plane=control,
+ )
+
+
+class ScorerTests(unittest.TestCase):
+ def setUp(self):
+ self.registry = {
+ "LegacyCobol": _agent(
+ "LegacyCobol",
+ ["cobol", "business_rule_extraction"],
+ deps=("Database", "Provenance"),
+ risk="high",
+ ),
+ "Database": _agent("Database", ["schema_analysis"], inputs=("db_schema",)),
+ "Provenance": _agent("Provenance", ["provenance_dag"]),
+ "Quant": _agent("Quant", ["pricing", "monte_carlo"]),
+ "Risk": _agent("Risk", ["risk_policy"], control=True, risk="critical"),
+ "Response": _agent("Response", ["synthesis"], control=True),
+ }
+ self.candidates = list(self.registry.values())
+ self.req = RequestContext(
+ required_capabilities=frozenset({"cobol", "business_rule_extraction"}),
+ available_inputs=frozenset({"source_code", "copybooks"}),
+ granted_permissions=frozenset({"read_source", "read_copybooks"}),
+ max_risk_class="high",
+ semantic_scores={
+ "LegacyCobol": 0.92,
+ "Database": 0.4,
+ "Quant": 0.1,
+ "Provenance": 0.3,
+ "Risk": 0.2,
+ "Response": 0.2,
+ },
+ )
+
+ def test_cobol_query_excludes_quant(self):
+ selected = select_minimum_set(self.candidates, self.req, self.registry)
+ self.assertIn("LegacyCobol", selected)
+ self.assertIn("Database", selected)
+ self.assertIn("Provenance", selected)
+ self.assertNotIn("Quant", selected)
+ self.assertIn("Risk", selected)
+ self.assertIn("Response", selected)
+
+ def test_score_orders_legacy_above_quant(self):
+ legacy = score_agent(self.registry["LegacyCobol"], self.req)
+ quant = score_agent(self.registry["Quant"], self.req)
+ self.assertGreater(legacy, quant)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/router/test_symbolic.py b/router/test_symbolic.py
new file mode 100644
index 0000000000000000000000000000000000000000..03ba649fee8adacf89241de7db597b997ee00d2e
--- /dev/null
+++ b/router/test_symbolic.py
@@ -0,0 +1,124 @@
+"""Smoke tests for symbolic hard filters + minimum_agent_set."""
+
+from __future__ import annotations
+
+import unittest
+
+from scorer import AgentDescriptor, RequestContext
+from symbolic import (
+ apply_symbolic_filters,
+ capability_eligible,
+ minimum_agent_set,
+ permission_eligible,
+ risk_eligible,
+)
+
+
+def _agent(aid, caps, deps=(), control=False, risk="medium", perms=(), inputs=()):
+ return AgentDescriptor(
+ agent_id=aid,
+ domain="test",
+ capabilities=frozenset(caps),
+ input_types=frozenset(inputs or ("source_code",)),
+ permissions=frozenset(perms or ("read_source",)),
+ risk_class=risk,
+ dependencies=frozenset(deps),
+ historical_success=0.5,
+ control_plane=control,
+ )
+
+
+class SymbolicTests(unittest.TestCase):
+ def setUp(self):
+ self.registry = {
+ "LegacyCobol": _agent(
+ "LegacyCobol",
+ ["cobol", "business_rule_extraction"],
+ deps=("Database", "Provenance"),
+ risk="high",
+ ),
+ "Database": _agent("Database", ["schema_analysis"], inputs=("db_schema",)),
+ "Provenance": _agent("Provenance", ["provenance_dag"]),
+ "Quant": _agent("Quant", ["pricing", "monte_carlo"]),
+ "Risk": _agent("Risk", ["risk_policy"], control=True, risk="critical"),
+ "Response": _agent("Response", ["synthesis"], control=True),
+ }
+ self.candidates = list(self.registry.values())
+ self.req = RequestContext(
+ required_capabilities=frozenset({"cobol", "business_rule_extraction"}),
+ available_inputs=frozenset({"source_code", "copybooks"}),
+ granted_permissions=frozenset({"read_source", "read_copybooks"}),
+ max_risk_class="high",
+ semantic_scores={
+ "LegacyCobol": 0.92,
+ "Database": 0.4,
+ "Quant": 0.1,
+ "Provenance": 0.3,
+ "Risk": 0.2,
+ "Response": 0.2,
+ },
+ )
+
+ def test_cobol_query_minimum_set(self):
+ selected = minimum_agent_set(self.candidates, self.req, self.registry)
+ self.assertIn("LegacyCobol", selected)
+ self.assertIn("Database", selected)
+ self.assertIn("Provenance", selected)
+ self.assertNotIn("Quant", selected)
+ self.assertIn("Risk", selected)
+ self.assertIn("Response", selected)
+
+ def test_quant_never_selected_for_cobol(self):
+ selected = minimum_agent_set(self.candidates, self.req, self.registry)
+ self.assertNotIn("Quant", selected)
+ self.assertFalse(capability_eligible(self.registry["Quant"], self.req))
+
+ def test_permission_filter_drops_ungranted(self):
+ sneaky = _agent(
+ "SneakyWrite",
+ ["cobol"],
+ perms=("read_source", "write_prod"),
+ )
+ req = RequestContext(
+ required_capabilities=frozenset({"cobol"}),
+ available_inputs=frozenset({"source_code"}),
+ granted_permissions=frozenset({"read_source"}),
+ max_risk_class="high",
+ semantic_scores={"SneakyWrite": 0.99},
+ )
+ self.assertFalse(permission_eligible(sneaky, req))
+ filtered = apply_symbolic_filters([sneaky], req)
+ self.assertEqual(filtered, [])
+ selected = minimum_agent_set([sneaky], req, {"SneakyWrite": sneaky})
+ self.assertNotIn("SneakyWrite", selected)
+
+ def test_risk_filter_drops_critical_when_max_medium(self):
+ critical = _agent("HotPath", ["cobol"], risk="critical")
+ req = RequestContext(
+ required_capabilities=frozenset({"cobol"}),
+ available_inputs=frozenset({"source_code"}),
+ granted_permissions=frozenset({"read_source"}),
+ max_risk_class="medium",
+ semantic_scores={"HotPath": 0.99, "Risk": 0.2, "Response": 0.2},
+ )
+ self.assertFalse(risk_eligible(critical, req))
+ filtered = apply_symbolic_filters([critical], req)
+ self.assertEqual(filtered, [])
+
+ # Mandatory control plane still included despite Risk being critical
+ # (risk gate skipped for mandatory CP; permission still required).
+ registry = {
+ "HotPath": critical,
+ "Risk": self.registry["Risk"],
+ "Response": self.registry["Response"],
+ }
+ selected = minimum_agent_set(
+ list(registry.values()), req, registry
+ )
+ self.assertNotIn("HotPath", selected)
+ self.assertIn("Risk", selected)
+ self.assertIn("Response", selected)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/README_agent_ann.md b/scripts/README_agent_ann.md
new file mode 100644
index 0000000000000000000000000000000000000000..a9d2969182588d188b7bfba25407107ce16a7345
--- /dev/null
+++ b/scripts/README_agent_ann.md
@@ -0,0 +1,11 @@
+# Live AGENT-index Postgres smoke
+
+Product indexes: `sql/001_agent_and_evidence_indexes.sql` (pgvector).
+
+Harness (Python, not product):
+- `load_agent_registry.py` — `registry/seed_agents.yaml` → `agent_registry`, deterministic hashed bag-of-tokens → `vector(1536)`
+- `agent_ann_smoke.py` — COBOL balance query ANN top-K (`<=>` cosine)
+
+Done criteria: LegacyCobol in top-K; Quant not preferred before symbolic filter.
+
+Measured log: `logs/agent_ann_smoke.log`
diff --git a/scripts/README_evidence_hybrid.md b/scripts/README_evidence_hybrid.md
new file mode 100644
index 0000000000000000000000000000000000000000..171e3bb36b31d18be3d5f82b696d2bbcadc401a8
--- /dev/null
+++ b/scripts/README_evidence_hybrid.md
@@ -0,0 +1,16 @@
+# Live EVIDENCE-index Postgres hybrid smoke
+
+Product indexes / retrieve: `sql/001_agent_and_evidence_indexes.sql`,
+`sql/002_evidence_hybrid_retrieve.sql`, fixtures in `sql/003_evidence_seed_cobol.sql`
+(embedding left NULL in 003 — loader fills vectors).
+
+Harness (Python, not product):
+- `load_evidence_seed.py` — same fixture IDs as 003 → `evidence`, deterministic hashed
+ bag-of-tokens → `vector(1536)` via `embed` / `fmt_vector` from `load_agent_registry.py`
+- `evidence_hybrid_smoke.py` — COBOL balance hybrid query (dense + lexical + authority;
+ `min_authority=0.5`; `require_parent=false` — junk dropped by authority floor)
+
+Done criteria: `ev_copybook_01` / `ev_cobol_src_01` / `ev_db2_schema_01` beat Quant;
+`ev_junk_low_auth` dropped at `min_authority=0.5`.
+
+Measured log: `logs/evidence_hybrid_smoke.log`
diff --git a/scripts/agent_ann_smoke.py b/scripts/agent_ann_smoke.py
new file mode 100644
index 0000000000000000000000000000000000000000..ca184421a1120f613d7ce4803d6d5ed421f99f6f
--- /dev/null
+++ b/scripts/agent_ann_smoke.py
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+"""Harness: embed COBOL balance query, ANN top-K against agent_registry."""
+from __future__ import annotations
+
+import math
+import sys
+from pathlib import Path
+
+# reuse embed from loader
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from load_agent_registry import embed, fmt_vector
+
+import psycopg2
+
+QUERY = (
+ "Explain how this COBOL payment program calculates the account balance "
+ "and determine whether its Java replacement preserves the behavior. "
+ "cobol copybook business_rule_extraction control_flow data_flow "
+ "legacy_mainframe modernization_analysis"
+)
+TOP_K = 5
+
+
+def main() -> int:
+ dsn = sys.argv[1] if len(sys.argv) > 1 else "dbname=david user=david host=127.0.0.1"
+ qvec = fmt_vector(embed(QUERY))
+ conn = psycopg2.connect(dsn)
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT agent_id, domain, risk_class,
+ 1 - (embedding <=> %s::vector) AS cosine_sim
+ FROM agent_registry
+ WHERE embedding IS NOT NULL
+ ORDER BY embedding <=> %s::vector
+ LIMIT %s
+ """,
+ (qvec, qvec, TOP_K),
+ )
+ rows = cur.fetchall()
+ conn.close()
+
+ print(f"query={QUERY[:80]}...")
+ print(f"top_k={TOP_K}")
+ for i, (aid, domain, risk, sim) in enumerate(rows, 1):
+ print(f"{i}\t{aid}\t{domain}\t{risk}\tsim={sim:.6f}")
+
+ ids = [r[0] for r in rows]
+ ok_legacy = "LegacyCobol" in ids
+ # Quant should not outrank LegacyCobol (prefer: Legacy ahead of Quant if both present)
+ if "Quant" in ids and "LegacyCobol" in ids:
+ ok_order = ids.index("LegacyCobol") < ids.index("Quant")
+ else:
+ ok_order = "Quant" not in ids[:1] # Quant not the preferred top hit
+
+ print(f"legacy_in_topk={ok_legacy}")
+ print(f"quant_not_preferred={ok_order}")
+ print(f"pass={ok_legacy and ok_order}")
+ return 0 if (ok_legacy and ok_order) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/check_prolog_registry_sync.sh b/scripts/check_prolog_registry_sync.sh
new file mode 100644
index 0000000000000000000000000000000000000000..f9bf4245d2e726f07ee13dbb955369d72f751d66
--- /dev/null
+++ b/scripts/check_prolog_registry_sync.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Harness: regenerate registry_facts.pl and fail if dirty vs committed tree.
+# Exit 0 iff generated facts match router/registry_facts.pl on disk.
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+GEN="$ROOT/scripts/gen_registry_facts.py"
+TARGET="$ROOT/router/registry_facts.pl"
+TMP="$(mktemp "${TMPDIR:-/tmp}/registry_facts.XXXXXX.pl")"
+trap 'rm -f "$TMP"' EXIT
+
+python3 "$GEN" "$TMP"
+if ! cmp -s "$TMP" "$TARGET"; then
+ echo "FAIL: router/registry_facts.pl out of sync with registry/seed_agents.yaml" >&2
+ echo "Diff (generated vs on-disk):" >&2
+ diff -u "$TARGET" "$TMP" >&2 || true
+ echo "Re-run: python3 scripts/gen_registry_facts.py" >&2
+ exit 1
+fi
+
+# Also require that symbolic_rules.pl includes the generated facts.
+if ! grep -q "include('registry_facts.pl')" "$ROOT/router/symbolic_rules.pl" \
+ && ! grep -q 'include("registry_facts.pl")' "$ROOT/router/symbolic_rules.pl" \
+ && ! grep -q "include(registry_facts)" "$ROOT/router/symbolic_rules.pl"; then
+ echo "FAIL: symbolic_rules.pl must include registry_facts.pl" >&2
+ exit 1
+fi
+
+# Spot-check: every agent_id from YAML appears as agent_id/2 in facts.
+python3 - <<'PY' "$ROOT"
+import re, sys
+from pathlib import Path
+root = Path(sys.argv[1])
+yaml_text = (root / "registry" / "seed_agents.yaml").read_text()
+facts = (root / "router" / "registry_facts.pl").read_text()
+ids = re.findall(r"(?m)^-\s*agent_id:\s*(\S+)", yaml_text)
+missing = [i for i in ids if f"agent_id('{i}'" not in facts]
+if missing:
+ print("FAIL: missing agent_id/2 for:", ", ".join(missing), file=sys.stderr)
+ sys.exit(1)
+if "agent(quant)." not in facts:
+ print("FAIL: Quant negative agent missing", file=sys.stderr)
+ sys.exit(1)
+print(f"sync ok: {len(ids)} agents; Quant present as negative")
+PY
+
+echo "prolog_registry_sync: OK"
diff --git a/scripts/evidence_hybrid_smoke.py b/scripts/evidence_hybrid_smoke.py
new file mode 100644
index 0000000000000000000000000000000000000000..5943450b547375aa2faacf2348b41311376eef46
--- /dev/null
+++ b/scripts/evidence_hybrid_smoke.py
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+"""Harness: embed COBOL balance query, hybrid retrieve against evidence (sql/002)."""
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from load_agent_registry import embed, fmt_vector
+from load_evidence_seed import normalize_for_embed
+
+import psycopg2
+
+QUERY = (
+ "Explain how this COBOL payment program calculates the account balance "
+ "and determine whether its Java replacement preserves the behavior. "
+ "cobol payment balance account CALC-BALANCE copybook"
+)
+# Lexical param: keyword phrase so plainto_tsquery AND-gate can fire on fixtures
+# (full prose AND-query matches almost nothing). Dense still uses full QUERY.
+LEXICAL_QUERY = "payment balance account"
+TOP_K = 5
+MIN_AUTHORITY = 0.5
+# Junk has empty parents AND low auth; min_authority alone drops it.
+REQUIRE_PARENT = False
+
+# Product hybrid SQL (equivalent to sql/002_evidence_hybrid_retrieve.sql)
+HYBRID_SQL = """
+SELECT
+ evidence_id,
+ source_id,
+ source_type,
+ document_id,
+ location,
+ content,
+ content_hash,
+ source_authority,
+ parent_evidence,
+ (1.0 - (embedding <=> %s::vector)) AS dense,
+ ts_rank_cd(content_tsv, plainto_tsquery('english', %s)) AS lexical,
+ source_authority AS authority,
+ CASE
+ WHEN COALESCE(%s::boolean, false)
+ AND cardinality(parent_evidence) = 0
+ THEN 1.0
+ ELSE 0.0
+ END AS parent_penalty,
+ (
+ (1.0 - (embedding <=> %s::vector))
+ + ts_rank_cd(content_tsv, plainto_tsquery('english', %s))
+ + source_authority
+ - CASE
+ WHEN COALESCE(%s::boolean, false)
+ AND cardinality(parent_evidence) = 0
+ THEN 1.0
+ ELSE 0.0
+ END
+ ) AS score
+FROM evidence
+WHERE
+ source_authority >= COALESCE(%s::real, 0.0)
+ AND (
+ %s::text[] IS NULL
+ OR cardinality(%s::text[]) = 0
+ OR source_type = ANY (%s::text[])
+ )
+ AND (
+ NOT COALESCE(%s::boolean, false)
+ OR cardinality(parent_evidence) > 0
+ )
+ AND (
+ content_tsv @@ plainto_tsquery('english', %s)
+ OR embedding IS NOT NULL
+ )
+ORDER BY score DESC, evidence_id
+LIMIT GREATEST(COALESCE(%s::int, 10), 0)
+"""
+
+LEGACY_IDS = ("ev_copybook_01", "ev_cobol_src_01", "ev_db2_schema_01")
+QUANT_ID = "ev_quant_pricing_01"
+JUNK_ID = "ev_junk_low_auth"
+
+
+def main() -> int:
+ dsn = sys.argv[1] if len(sys.argv) > 1 else "dbname=david user=david host=127.0.0.1"
+ qvec = fmt_vector(embed(normalize_for_embed(QUERY)))
+ conn = psycopg2.connect(dsn)
+ with conn.cursor() as cur:
+ cur.execute(
+ "SELECT evidence_id FROM evidence WHERE evidence_id = %s", (JUNK_ID,)
+ )
+ junk_exists = cur.fetchone() is not None
+
+ cur.execute(
+ HYBRID_SQL,
+ (
+ qvec,
+ LEXICAL_QUERY,
+ REQUIRE_PARENT,
+ qvec,
+ LEXICAL_QUERY,
+ REQUIRE_PARENT,
+ MIN_AUTHORITY,
+ None,
+ None,
+ None,
+ REQUIRE_PARENT,
+ LEXICAL_QUERY,
+ TOP_K,
+ ),
+ )
+ rows = cur.fetchall()
+
+ cur.execute(
+ """
+ SELECT evidence_id FROM evidence
+ WHERE source_authority >= %s
+ AND evidence_id = %s
+ """,
+ (MIN_AUTHORITY, JUNK_ID),
+ )
+ junk_in_auth_filter = cur.fetchone() is not None
+ conn.close()
+
+ print(f"query={QUERY[:80]}...")
+ print(f"lexical_query={LEXICAL_QUERY}")
+ print(f"top_k={TOP_K} min_authority={MIN_AUTHORITY} require_parent={REQUIRE_PARENT}")
+ print("rank\tevidence_id\tsource_type\tscore\tdense\tlexical\tauthority")
+ ranked = []
+ for i, row in enumerate(rows, 1):
+ eid = row[0]
+ stype = row[2]
+ dense = float(row[9])
+ lexical = float(row[10])
+ authority = float(row[11])
+ score = float(row[13])
+ ranked.append(eid)
+ print(
+ f"{i}\t{eid}\t{stype}\tscore={score:.6f}\t"
+ f"dense={dense:.6f}\tlexical={lexical:.6f}\tauthority={authority:.6f}"
+ )
+
+ junk_dropped = junk_exists and (JUNK_ID not in ranked) and (not junk_in_auth_filter)
+ quant_not_first = QUANT_ID not in ranked or ranked[0] != QUANT_ID
+
+ legacy_beat_quant = True
+ for lid in LEGACY_IDS:
+ if lid in ranked and QUANT_ID in ranked:
+ if ranked.index(lid) > ranked.index(QUANT_ID):
+ legacy_beat_quant = False
+ break
+ elif QUANT_ID in ranked and lid not in ranked:
+ legacy_beat_quant = False
+ break
+
+ legacy_in_topk = all(lid in ranked for lid in LEGACY_IDS)
+ if QUANT_ID not in ranked:
+ legacy_beat_quant = legacy_in_topk or any(lid in ranked for lid in LEGACY_IDS)
+
+ ok = junk_dropped and quant_not_first and legacy_beat_quant and legacy_in_topk
+
+ print(f"junk_exists={junk_exists}")
+ print(f"junk_dropped={junk_dropped}")
+ print(f"legacy_in_topk={legacy_in_topk}")
+ print(f"quant_not_preferred={quant_not_first and legacy_beat_quant}")
+ print(f"pass={ok}")
+ return 0 if ok else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/gen_registry_facts.py b/scripts/gen_registry_facts.py
new file mode 100644
index 0000000000000000000000000000000000000000..0439f4fff29b6dec190a5a17c001757f8ce71ed0
--- /dev/null
+++ b/scripts/gen_registry_facts.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""Harness: registry/seed_agents.yaml → router/registry_facts.pl (stdlib + PyYAML).
+
+Not product code. Regenerates Prolog agent facts so symbolic_rules.pl stays
+aligned with the seed registry. Prefer: scripts/check_prolog_registry_sync.sh
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+try:
+ import yaml
+except ImportError:
+ yaml = None
+
+ROOT = Path(__file__).resolve().parents[1]
+SEED = ROOT / "registry" / "seed_agents.yaml"
+OUT = ROOT / "router" / "registry_facts.pl"
+
+
+def to_atom(agent_id: str) -> str:
+ """Registry PascalCase id → Prolog atom (downcase first letter)."""
+ if not agent_id:
+ raise ValueError("empty agent_id")
+ return agent_id[0].lower() + agent_id[1:]
+
+
+def parse_seed_stdlib(text: str) -> list[dict]:
+ """Minimal YAML subset parser for seed_agents.yaml (no PyYAML required)."""
+ agents: list[dict] = []
+ cur: dict | None = None
+ list_key: str | None = None
+
+ def flush():
+ nonlocal cur
+ if cur is not None:
+ agents.append(cur)
+ cur = None
+
+ for raw in text.splitlines():
+ if not raw.strip() or raw.lstrip().startswith("#"):
+ continue
+ if raw.startswith("- agent_id:"):
+ flush()
+ cur = {
+ "agent_id": raw.split(":", 1)[1].strip(),
+ "capabilities": [],
+ "permissions": [],
+ "dependencies": [],
+ "control_plane": False,
+ }
+ list_key = None
+ continue
+ if cur is None:
+ continue
+ indent = len(raw) - len(raw.lstrip(" "))
+ s = raw.strip()
+ if indent == 2 and ":" in s and not s.startswith("-"):
+ key, _, val = s.partition(":")
+ key = key.strip()
+ val = val.strip()
+ list_key = None
+ if key in ("capabilities", "permissions", "dependencies", "languages",
+ "input_types", "output_types", "required_evidence"):
+ if val.startswith("[") and val.endswith("]"):
+ inner = val[1:-1].strip()
+ cur[key] = [x.strip() for x in inner.split(",") if x.strip()] if inner else []
+ elif val == "":
+ list_key = key
+ cur[key] = []
+ else:
+ cur[key] = [val]
+ elif key == "control_plane":
+ cur[key] = val.lower() in ("true", "yes", "1")
+ elif key == "risk_class":
+ cur[key] = val
+ elif key == "domain":
+ cur[key] = val
+ else:
+ cur[key] = val
+ elif indent >= 4 and s.startswith("- ") and list_key:
+ cur.setdefault(list_key, []).append(s[2:].strip())
+ flush()
+ return agents
+
+
+def load_agents() -> list[dict]:
+ text = SEED.read_text(encoding="utf-8")
+ if yaml is not None:
+ data = yaml.safe_load(text)
+ if not isinstance(data, list):
+ raise SystemExit("seed_agents.yaml: expected list")
+ return data
+ return parse_seed_stdlib(text)
+
+
+def emit(agents: list[dict]) -> str:
+ lines: list[str] = [
+ "% SPDX-License-Identifier: AGPL-3.0-only",
+ "% Copyright (C) 2026 David contributors",
+ "% GENERATED by scripts/gen_registry_facts.py from registry/seed_agents.yaml",
+ "% Do not edit by hand — regenerate via scripts/check_prolog_registry_sync.sh",
+ "%",
+ "% Agent atom map: registry agent_id → Prolog atom = downcase first letter",
+ "% LegacyCobol → legacyCobol, Risk → risk, Quant → quant",
+ "% Predicates: agent_id/2, agent/1, control_plane/1, capability/2,",
+ "% permission/2, risk_class/2, depends/2",
+ "",
+ ]
+ # agent_id/2 map first
+ for a in agents:
+ rid = a["agent_id"]
+ atom = to_atom(rid)
+ lines.append(f"agent_id('{rid}', {atom}).")
+ lines.append("")
+ for a in agents:
+ atom = to_atom(a["agent_id"])
+ lines.append(f"agent({atom}).")
+ lines.append("")
+ for a in agents:
+ if a.get("control_plane"):
+ lines.append(f"control_plane({to_atom(a['agent_id'])}).")
+ lines.append("")
+ for a in agents:
+ atom = to_atom(a["agent_id"])
+ for cap in a.get("capabilities") or []:
+ lines.append(f"capability({atom}, {cap}).")
+ lines.append("")
+ for a in agents:
+ atom = to_atom(a["agent_id"])
+ for perm in a.get("permissions") or []:
+ lines.append(f"permission({atom}, {perm}).")
+ lines.append("")
+ for a in agents:
+ atom = to_atom(a["agent_id"])
+ rc = a.get("risk_class")
+ if not rc:
+ raise SystemExit(f"missing risk_class for {a['agent_id']}")
+ lines.append(f"risk_class({atom}, {rc}).")
+ lines.append("")
+ for a in agents:
+ atom = to_atom(a["agent_id"])
+ for dep in a.get("dependencies") or []:
+ lines.append(f"depends({atom}, {to_atom(dep)}).")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def main() -> int:
+ out = Path(sys.argv[1]) if len(sys.argv) > 1 else OUT
+ agents = load_agents()
+ if not agents:
+ print("no agents loaded", file=sys.stderr)
+ return 1
+ text = emit(agents)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ out.write_text(text, encoding="utf-8")
+ print(f"wrote {out} ({len(agents)} agents)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/load_agent_registry.py b/scripts/load_agent_registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..0eb01676f1ea4d2ab55d646bc233d76d2d33d334
--- /dev/null
+++ b/scripts/load_agent_registry.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""Harness: seed_agents.yaml → agent_registry with deterministic 1536-d vectors."""
+from __future__ import annotations
+
+import hashlib
+import math
+import re
+import sys
+from pathlib import Path
+
+import yaml
+
+try:
+ import psycopg2
+ from psycopg2.extras import execute_values
+except ImportError:
+ psycopg2 = None
+
+ROOT = Path(__file__).resolve().parents[1]
+SEED = ROOT / "registry" / "seed_agents.yaml"
+DIM = 1536
+
+TOKEN_RE = re.compile(r"[a-z0-9_]+")
+
+
+def embed_text_of(agent: dict) -> str:
+ parts = [
+ agent["agent_id"],
+ agent.get("domain", ""),
+ " ".join(agent.get("capabilities") or []),
+ " ".join(agent.get("languages") or []),
+ " ".join(agent.get("input_types") or []),
+ " ".join(agent.get("output_types") or []),
+ " ".join(agent.get("dependencies") or []),
+ agent.get("risk_class", ""),
+ ]
+ return " ".join(p for p in parts if p).lower()
+
+
+def embed(text: str, dim: int = DIM) -> list[float]:
+ """Deterministic hashed bag-of-tokens → L2-normalized vector(dim)."""
+ vec = [0.0] * dim
+ toks = TOKEN_RE.findall(text.lower())
+ if not toks:
+ vec[0] = 1.0
+ return vec
+ for tok in toks:
+ h = hashlib.sha256(tok.encode()).digest()
+ # two lanes per token for a bit of spread
+ for off in (0, 8):
+ idx = int.from_bytes(h[off : off + 4], "little") % dim
+ sign = 1.0 if h[off + 4] & 1 else -1.0
+ vec[idx] += sign
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
+ return [v / norm for v in vec]
+
+
+def fmt_vector(v: list[float]) -> str:
+ return "[" + ",".join(f"{x:.8f}" for x in v) + "]"
+
+
+def main() -> int:
+ agents = yaml.safe_load(SEED.read_text())
+ dsn = sys.argv[1] if len(sys.argv) > 1 else "dbname=david user=david host=127.0.0.1"
+ if psycopg2 is None:
+ print("psycopg2 missing", file=sys.stderr)
+ return 2
+
+ rows = []
+ for a in agents:
+ et = embed_text_of(a)
+ emb = embed(et)
+ rows.append(
+ (
+ a["agent_id"],
+ a["domain"],
+ a.get("capabilities") or [],
+ a.get("languages") or [],
+ a.get("input_types") or [],
+ a.get("output_types") or [],
+ a.get("required_evidence") or [],
+ a.get("permissions") or [],
+ a["risk_class"],
+ a.get("dependencies") or [],
+ a["execution_endpoint"],
+ bool(a.get("control_plane", False)),
+ et,
+ fmt_vector(emb),
+ float(a.get("historical_success", 0.5)),
+ )
+ )
+
+ conn = psycopg2.connect(dsn)
+ conn.autocommit = True
+ with conn.cursor() as cur:
+ cur.execute("TRUNCATE agent_registry")
+ sql = """
+ INSERT INTO agent_registry (
+ agent_id, domain, capabilities, languages, input_types, output_types,
+ required_evidence, permissions, risk_class, dependencies,
+ execution_endpoint, control_plane, embed_text, embedding, historical_success
+ ) VALUES %s
+ """
+ template = (
+ "(%s,%s,%s::text[],%s::text[],%s::text[],%s::text[],"
+ "%s::text[],%s::text[],%s,%s::text[],%s,%s,%s,%s::vector,%s)"
+ )
+ execute_values(cur, sql, rows, template=template)
+ cur.execute("SELECT count(*) FROM agent_registry")
+ n = cur.fetchone()[0]
+ conn.close()
+ print(f"loaded_agents={n}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/load_evidence_seed.py b/scripts/load_evidence_seed.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a506eb055140de2023ad703f5b6f3ce1e5799fe
--- /dev/null
+++ b/scripts/load_evidence_seed.py
@@ -0,0 +1,182 @@
+#!/usr/bin/env python3
+"""Harness: COBOL evidence fixtures → evidence with deterministic 1536-d vectors.
+
+Mirrors sql/003_evidence_seed_cobol.sql IDs/content; fills embedding (003 leaves NULL).
+Reuse embed/fmt_vector from load_agent_registry (smoke hashed bag-of-tokens only).
+"""
+from __future__ import annotations
+
+import re
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from load_agent_registry import embed, fmt_vector
+
+try:
+ import psycopg2
+except ImportError:
+ psycopg2 = None
+
+# Same fixtures as sql/003_evidence_seed_cobol.sql
+FIXTURES = [
+ {
+ "evidence_id": "ev_copybook_01",
+ "source_id": "src_copybook_acct",
+ "source_type": "copybook",
+ "document_id": "ACCTREC.cpy",
+ "location": "01-ACCOUNT-BALANCE",
+ "content": (
+ "01 ACCOUNT-RECORD. 05 ACCOUNT-BALANCE PIC S9(9)V99. "
+ "05 PAYMENT-AMOUNT PIC S9(7)V99. 05 LAST-PAYMENT-DATE PIC X(8)."
+ ),
+ "content_hash": "seed_copybook_01",
+ "source_authority": 0.95,
+ "parent_evidence": ["ev_provenance_root"],
+ },
+ {
+ "evidence_id": "ev_cobol_src_01",
+ "source_id": "src_cobol_pay",
+ "source_type": "cobol_source",
+ "document_id": "PAYROLL.cbl",
+ "location": "PROCEDURE DIVISION / CALC-BALANCE",
+ "content": (
+ "COMPUTE ACCOUNT-BALANCE = ACCOUNT-BALANCE - PAYMENT-AMOUNT. "
+ "IF ACCOUNT-BALANCE < ZERO THEN PERFORM OVERDRAFT-CHECK. "
+ "MOVE PAYMENT-AMOUNT TO WS-LAST-PAYMENT."
+ ),
+ "content_hash": "seed_cobol_src_01",
+ "source_authority": 0.90,
+ "parent_evidence": ["ev_provenance_root"],
+ },
+ {
+ "evidence_id": "ev_db2_schema_01",
+ "source_id": "src_db2_acct",
+ "source_type": "db2_schema",
+ "document_id": "ACCT.DDL",
+ "location": "TABLE ACCOUNT_BALANCES",
+ "content": (
+ "CREATE TABLE ACCOUNT_BALANCES (ACCT_ID CHAR(12), BALANCE DECIMAL(11,2), "
+ "LAST_PAYMENT DECIMAL(9,2), UPDATED_TS TIMESTAMP)."
+ ),
+ "content_hash": "seed_db2_schema_01",
+ "source_authority": 0.85,
+ "parent_evidence": ["ev_provenance_root"],
+ },
+ {
+ "evidence_id": "ev_quant_pricing_01",
+ "source_id": "src_quant_report",
+ "source_type": "quant_report",
+ "document_id": "PRICING_Q3.pdf",
+ "location": "section:monte_carlo",
+ "content": (
+ "Monte Carlo pricing of exotic options. Implied volatility surface and "
+ "greek sensitivities. No COBOL or account balance fields."
+ ),
+ "content_hash": "seed_quant_pricing_01",
+ "source_authority": 0.80,
+ "parent_evidence": ["ev_provenance_root"],
+ },
+ {
+ "evidence_id": "ev_lexical_lift_01",
+ "source_id": "src_glossary",
+ "source_type": "glossary",
+ "document_id": "LEGACY-GLOSSARY.txt",
+ "location": "entry:PAYMENT-BALANCE",
+ "content": (
+ "cobol payment balance glossary entry: PAYMENT and BALANCE fields on "
+ "ACCOUNT records in legacy batch."
+ ),
+ "content_hash": "seed_lexical_lift_01",
+ "source_authority": 0.70,
+ "parent_evidence": ["ev_provenance_root"],
+ },
+ {
+ "evidence_id": "ev_junk_low_auth",
+ "source_id": "src_web_scrape",
+ "source_type": "web_scrape",
+ "document_id": "random-blog.html",
+ "location": "body",
+ "content": (
+ "Someone said COBOL payment balance might work like a spreadsheet. "
+ "Unverified forum post."
+ ),
+ "content_hash": "seed_junk_low_auth",
+ "source_authority": 0.10,
+ "parent_evidence": [],
+ },
+]
+
+
+def normalize_for_embed(text: str) -> str:
+ """Split identifiers on - _ / . so ACCOUNT_BALANCES → account balances tokens."""
+ return re.sub(r"[-_./]+", " ", text.lower())
+
+
+def embed_text_of(f: dict) -> str:
+ """content + source_type + document_id + location for dense path."""
+ parts = [
+ f.get("source_type") or "",
+ f.get("document_id") or "",
+ f.get("location") or "",
+ f["content"],
+ ]
+ return " ".join(p for p in parts if p)
+
+
+def main() -> int:
+ dsn = sys.argv[1] if len(sys.argv) > 1 else "dbname=david user=david host=127.0.0.1"
+ if psycopg2 is None:
+ print("psycopg2 missing", file=sys.stderr)
+ return 2
+
+ conn = psycopg2.connect(dsn)
+ conn.autocommit = True
+ with conn.cursor() as cur:
+ for f in FIXTURES:
+ et = normalize_for_embed(embed_text_of(f))
+ emb = fmt_vector(embed(et))
+ cur.execute(
+ """
+ INSERT INTO evidence (
+ evidence_id, source_id, source_type, document_id, location,
+ content, content_hash, embedding, source_authority, parent_evidence
+ ) VALUES (
+ %s, %s, %s, %s, %s,
+ %s, %s, %s::vector, %s, %s::text[]
+ )
+ ON CONFLICT (evidence_id) DO UPDATE SET
+ source_id = EXCLUDED.source_id,
+ source_type = EXCLUDED.source_type,
+ document_id = EXCLUDED.document_id,
+ location = EXCLUDED.location,
+ content = EXCLUDED.content,
+ content_hash = EXCLUDED.content_hash,
+ embedding = EXCLUDED.embedding,
+ source_authority = EXCLUDED.source_authority,
+ parent_evidence = EXCLUDED.parent_evidence
+ """,
+ (
+ f["evidence_id"],
+ f["source_id"],
+ f["source_type"],
+ f["document_id"],
+ f["location"],
+ f["content"],
+ f["content_hash"],
+ emb,
+ f["source_authority"],
+ f["parent_evidence"],
+ ),
+ )
+ cur.execute(
+ "SELECT count(*) FILTER (WHERE embedding IS NOT NULL), count(*) FROM evidence"
+ )
+ with_emb, total = cur.fetchone()
+ conn.close()
+ print(f"loaded_evidence={total} with_embedding={with_emb}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/prolog_selected_handoff.sh b/scripts/prolog_selected_handoff.sh
new file mode 100644
index 0000000000000000000000000000000000000000..8145ac5339236f5b152da04626080075c925cf44
--- /dev/null
+++ b/scripts/prolog_selected_handoff.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+# Materialize Prolog smoke_cobol selection as one agent_id per line.
+set -e
+cd "$(dirname "$0")/.."
+swipl -q -s router/symbolic_rules.pl -g smoke_cobol -t halt > logs/prolog_selected_cobol.raw
+sed -n 's/^selected:\[\(.*\)\]/\1/p' logs/prolog_selected_cobol.raw | tr ',' '\n' | tr -d ' []' | sed '/^$/d' > logs/selected_cobol.txt
diff --git a/sql/001_agent_and_evidence_indexes.sql b/sql/001_agent_and_evidence_indexes.sql
new file mode 100644
index 0000000000000000000000000000000000000000..c5bf26e36a1602b5f1d30d633f20b9b5888e6f39
--- /dev/null
+++ b/sql/001_agent_and_evidence_indexes.sql
@@ -0,0 +1,58 @@
+-- Synthetic David: separate AGENT and EVIDENCE vector spaces (pgvector)
+CREATE EXTENSION IF NOT EXISTS vector;
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+
+-- AGENT INDEX — "Who should work?"
+CREATE TABLE IF NOT EXISTS agent_registry (
+ agent_id text PRIMARY KEY,
+ domain text NOT NULL,
+ capabilities text[] NOT NULL DEFAULT '{}',
+ languages text[] NOT NULL DEFAULT '{}',
+ input_types text[] NOT NULL DEFAULT '{}',
+ output_types text[] NOT NULL DEFAULT '{}',
+ required_evidence text[] NOT NULL DEFAULT '{}',
+ permissions text[] NOT NULL DEFAULT '{}',
+ risk_class text NOT NULL CHECK (risk_class IN ('low','medium','high','critical')),
+ dependencies text[] NOT NULL DEFAULT '{}',
+ execution_endpoint text NOT NULL,
+ control_plane boolean NOT NULL DEFAULT false,
+ embed_text text NOT NULL,
+ embedding vector(1536),
+ historical_success real NOT NULL DEFAULT 0.0,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS agent_registry_embedding_hnsw
+ ON agent_registry USING hnsw (embedding vector_cosine_ops);
+
+CREATE INDEX IF NOT EXISTS agent_registry_capabilities_gin
+ ON agent_registry USING gin (capabilities);
+
+-- EVIDENCE INDEX — "What evidence should they use?"
+CREATE TABLE IF NOT EXISTS evidence (
+ evidence_id text PRIMARY KEY,
+ source_id text NOT NULL,
+ source_type text NOT NULL,
+ document_id text,
+ location text,
+ content text NOT NULL,
+ content_hash text NOT NULL,
+ embedding vector(1536),
+ content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
+ source_authority real NOT NULL DEFAULT 0.5,
+ retrieved_by text,
+ retrieval_score real,
+ parent_evidence text[] NOT NULL DEFAULT '{}',
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS evidence_embedding_hnsw
+ ON evidence USING hnsw (embedding vector_cosine_ops);
+
+CREATE INDEX IF NOT EXISTS evidence_content_tsv_gin
+ ON evidence USING gin (content_tsv);
+
+-- Hybrid evidence retrieval sketch:
+-- dense: ORDER BY embedding <=> $query_vec
+-- lexical: content_tsv @@ plainto_tsquery('english', $q)
+-- then metadata + provenance filters, then rerank.
diff --git a/sql/002_evidence_hybrid_retrieve.sql b/sql/002_evidence_hybrid_retrieve.sql
new file mode 100644
index 0000000000000000000000000000000000000000..dd3580490bf9ced09f7f8a91f0ba9a52958cb4a2
--- /dev/null
+++ b/sql/002_evidence_hybrid_retrieve.sql
@@ -0,0 +1,80 @@
+-- Synthetic David — product hybrid retrieve against `evidence`
+-- Schema: sql/001_agent_and_evidence_indexes.sql
+--
+-- Parameters (bind as $1..$n; comments document intent):
+-- $1 query_embedding vector(1536) -- dense cosine query
+-- $2 query_text text -- lexical plainto_tsquery input
+-- $3 source_types text[] -- optional; NULL / empty = no filter
+-- $4 min_authority real -- provenance floor on source_authority
+-- $5 require_parent boolean -- if true, parent_evidence must be non-empty
+-- $6 top_k int -- LIMIT
+--
+-- Score weights (deterministic merge / rerank — no dense floor):
+-- dense = 1 - (embedding <=> $1) -- cosine similarity from cosine distance
+-- lexical = ts_rank_cd(content_tsv, plainto_tsquery('english', $2))
+-- authority = source_authority
+-- penalty = CASE WHEN $5 AND cardinality(parent_evidence)=0 THEN 1.0 ELSE 0.0 END
+-- score = dense + lexical + authority - penalty
+--
+-- ORDER BY score DESC, evidence_id (stable tie-break)
+-- Hybrid note: weak dense + strong lexical can still rank (no hard dense floor).
+
+SELECT
+ evidence_id,
+ source_id,
+ source_type,
+ document_id,
+ location,
+ content,
+ content_hash,
+ source_authority,
+ parent_evidence,
+ -- component scores for retrieval_metadata / provenance
+ (1.0 - (embedding <=> $1::vector)) AS dense, -- weight 1.0
+ ts_rank_cd(content_tsv, plainto_tsquery('english', $2)) AS lexical, -- weight 1.0
+ source_authority AS authority, -- weight 1.0
+ CASE
+ WHEN COALESCE($5::boolean, false)
+ AND cardinality(parent_evidence) = 0
+ THEN 1.0
+ ELSE 0.0
+ END AS parent_penalty, -- weight 1.0
+ (
+ (1.0 - (embedding <=> $1::vector))
+ + ts_rank_cd(content_tsv, plainto_tsquery('english', $2))
+ + source_authority
+ - CASE
+ WHEN COALESCE($5::boolean, false)
+ AND cardinality(parent_evidence) = 0
+ THEN 1.0
+ ELSE 0.0
+ END
+ ) AS score
+FROM evidence
+WHERE
+ -- provenance: authority floor
+ source_authority >= COALESCE($4::real, 0.0)
+ -- optional metadata: source_type = ANY(...)
+ AND (
+ $3::text[] IS NULL
+ OR cardinality($3::text[]) = 0
+ OR source_type = ANY ($3::text[])
+ )
+ -- optional provenance: require non-empty parent_evidence
+ AND (
+ NOT COALESCE($5::boolean, false)
+ OR cardinality(parent_evidence) > 0
+ )
+ -- lexical gate (rows with zero tsquery match still allowed via dense path:
+ -- keep OR so weak-dense / strong-lexical AND strong-dense / weak-lexical both survive)
+ AND (
+ content_tsv @@ plainto_tsquery('english', $2)
+ OR embedding IS NOT NULL
+ )
+ORDER BY score DESC, evidence_id
+LIMIT GREATEST(COALESCE($6::int, 10), 0);
+
+-- Dense-only sketch (reference):
+-- ORDER BY embedding <=> $1
+-- Lexical-only sketch (reference):
+-- WHERE content_tsv @@ plainto_tsquery('english', $2)
diff --git a/sql/003_evidence_seed_cobol.sql b/sql/003_evidence_seed_cobol.sql
new file mode 100644
index 0000000000000000000000000000000000000000..e2976994eb90b094dd1c3813e83d8799ca3db4ed
--- /dev/null
+++ b/sql/003_evidence_seed_cobol.sql
@@ -0,0 +1,81 @@
+-- Synthetic David — COBOL modernization seed fixture (mirrors j/evidence.ijs)
+-- embedding left NULL until an encoder exists; content/authority/parent drive smoke filters.
+-- Fixture evidence_ids match pure-J seed corpus.
+
+INSERT INTO evidence (
+ evidence_id, source_id, source_type, document_id, location,
+ content, content_hash, embedding, source_authority, parent_evidence
+) VALUES
+(
+ 'ev_copybook_01',
+ 'src_copybook_acct',
+ 'copybook',
+ 'ACCTREC.cpy',
+ '01-ACCOUNT-BALANCE',
+ '01 ACCOUNT-RECORD. 05 ACCOUNT-BALANCE PIC S9(9)V99. 05 PAYMENT-AMOUNT PIC S9(7)V99. 05 LAST-PAYMENT-DATE PIC X(8).',
+ 'seed_copybook_01',
+ NULL, -- vector(1536) when encoder exists; J smoke uses dim-8 stand-in
+ 0.95,
+ ARRAY['ev_provenance_root']
+),
+(
+ 'ev_cobol_src_01',
+ 'src_cobol_pay',
+ 'cobol_source',
+ 'PAYROLL.cbl',
+ 'PROCEDURE DIVISION / CALC-BALANCE',
+ 'COMPUTE ACCOUNT-BALANCE = ACCOUNT-BALANCE - PAYMENT-AMOUNT. IF ACCOUNT-BALANCE < ZERO THEN PERFORM OVERDRAFT-CHECK. MOVE PAYMENT-AMOUNT TO WS-LAST-PAYMENT.',
+ 'seed_cobol_src_01',
+ NULL,
+ 0.90,
+ ARRAY['ev_provenance_root']
+),
+(
+ 'ev_db2_schema_01',
+ 'src_db2_acct',
+ 'db2_schema',
+ 'ACCT.DDL',
+ 'TABLE ACCOUNT_BALANCES',
+ 'CREATE TABLE ACCOUNT_BALANCES (ACCT_ID CHAR(12), BALANCE DECIMAL(11,2), LAST_PAYMENT DECIMAL(9,2), UPDATED_TS TIMESTAMP).',
+ 'seed_db2_schema_01',
+ NULL,
+ 0.85,
+ ARRAY['ev_provenance_root']
+),
+(
+ 'ev_quant_pricing_01',
+ 'src_quant_report',
+ 'quant_report',
+ 'PRICING_Q3.pdf',
+ 'section:monte_carlo',
+ 'Monte Carlo pricing of exotic options. Implied volatility surface and greek sensitivities. No COBOL or account balance fields.',
+ 'seed_quant_pricing_01',
+ NULL,
+ 0.80,
+ ARRAY['ev_provenance_root']
+),
+(
+ 'ev_lexical_lift_01',
+ 'src_glossary',
+ 'glossary',
+ 'LEGACY-GLOSSARY.txt',
+ 'entry:PAYMENT-BALANCE',
+ 'cobol payment balance glossary entry: PAYMENT and BALANCE fields on ACCOUNT records in legacy batch.',
+ 'seed_lexical_lift_01',
+ NULL,
+ 0.70,
+ ARRAY['ev_provenance_root']
+),
+(
+ 'ev_junk_low_auth',
+ 'src_web_scrape',
+ 'web_scrape',
+ 'random-blog.html',
+ 'body',
+ 'Someone said COBOL payment balance might work like a spreadsheet. Unverified forum post.',
+ 'seed_junk_low_auth',
+ NULL,
+ 0.10,
+ ARRAY[]::text[]
+)
+ON CONFLICT (evidence_id) DO NOTHING;