edsl 0.1.39.dev3__py3-none-any.whl → 0.1.39.dev4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. edsl/Base.py +413 -332
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +57 -49
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +1071 -867
  7. edsl/agents/AgentList.py +551 -413
  8. edsl/agents/Invigilator.py +284 -233
  9. edsl/agents/InvigilatorBase.py +257 -270
  10. edsl/agents/PromptConstructor.py +272 -354
  11. edsl/agents/QuestionInstructionPromptBuilder.py +128 -0
  12. edsl/agents/QuestionTemplateReplacementsBuilder.py +137 -0
  13. edsl/agents/__init__.py +2 -3
  14. edsl/agents/descriptors.py +99 -99
  15. edsl/agents/prompt_helpers.py +129 -129
  16. edsl/agents/question_option_processor.py +172 -0
  17. edsl/auto/AutoStudy.py +130 -117
  18. edsl/auto/StageBase.py +243 -230
  19. edsl/auto/StageGenerateSurvey.py +178 -178
  20. edsl/auto/StageLabelQuestions.py +125 -125
  21. edsl/auto/StagePersona.py +61 -61
  22. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  23. edsl/auto/StagePersonaDimensionValues.py +74 -74
  24. edsl/auto/StagePersonaDimensions.py +69 -69
  25. edsl/auto/StageQuestions.py +74 -73
  26. edsl/auto/SurveyCreatorPipeline.py +21 -21
  27. edsl/auto/utilities.py +218 -224
  28. edsl/base/Base.py +279 -279
  29. edsl/config.py +177 -157
  30. edsl/conversation/Conversation.py +290 -290
  31. edsl/conversation/car_buying.py +59 -58
  32. edsl/conversation/chips.py +95 -95
  33. edsl/conversation/mug_negotiation.py +81 -81
  34. edsl/conversation/next_speaker_utilities.py +93 -93
  35. edsl/coop/CoopFunctionsMixin.py +15 -0
  36. edsl/coop/ExpectedParrotKeyHandler.py +125 -0
  37. edsl/coop/PriceFetcher.py +54 -54
  38. edsl/coop/__init__.py +2 -2
  39. edsl/coop/coop.py +1106 -1028
  40. edsl/coop/utils.py +131 -131
  41. edsl/data/Cache.py +573 -555
  42. edsl/data/CacheEntry.py +230 -233
  43. edsl/data/CacheHandler.py +168 -149
  44. edsl/data/RemoteCacheSync.py +186 -78
  45. edsl/data/SQLiteDict.py +292 -292
  46. edsl/data/__init__.py +5 -4
  47. edsl/data/hack.py +10 -0
  48. edsl/data/orm.py +10 -10
  49. edsl/data_transfer_models.py +74 -73
  50. edsl/enums.py +202 -175
  51. edsl/exceptions/BaseException.py +21 -21
  52. edsl/exceptions/__init__.py +54 -54
  53. edsl/exceptions/agents.py +54 -42
  54. edsl/exceptions/cache.py +5 -5
  55. edsl/exceptions/configuration.py +16 -16
  56. edsl/exceptions/coop.py +10 -10
  57. edsl/exceptions/data.py +14 -14
  58. edsl/exceptions/general.py +34 -34
  59. edsl/exceptions/inference_services.py +5 -0
  60. edsl/exceptions/jobs.py +33 -33
  61. edsl/exceptions/language_models.py +63 -63
  62. edsl/exceptions/prompts.py +15 -15
  63. edsl/exceptions/questions.py +109 -91
  64. edsl/exceptions/results.py +29 -29
  65. edsl/exceptions/scenarios.py +29 -22
  66. edsl/exceptions/surveys.py +37 -37
  67. edsl/inference_services/AnthropicService.py +106 -87
  68. edsl/inference_services/AvailableModelCacheHandler.py +184 -0
  69. edsl/inference_services/AvailableModelFetcher.py +215 -0
  70. edsl/inference_services/AwsBedrock.py +118 -120
  71. edsl/inference_services/AzureAI.py +215 -217
  72. edsl/inference_services/DeepInfraService.py +18 -18
  73. edsl/inference_services/GoogleService.py +143 -148
  74. edsl/inference_services/GroqService.py +20 -20
  75. edsl/inference_services/InferenceServiceABC.py +80 -147
  76. edsl/inference_services/InferenceServicesCollection.py +138 -97
  77. edsl/inference_services/MistralAIService.py +120 -123
  78. edsl/inference_services/OllamaService.py +18 -18
  79. edsl/inference_services/OpenAIService.py +236 -224
  80. edsl/inference_services/PerplexityService.py +160 -163
  81. edsl/inference_services/ServiceAvailability.py +135 -0
  82. edsl/inference_services/TestService.py +90 -89
  83. edsl/inference_services/TogetherAIService.py +172 -170
  84. edsl/inference_services/data_structures.py +134 -0
  85. edsl/inference_services/models_available_cache.py +118 -118
  86. edsl/inference_services/rate_limits_cache.py +25 -25
  87. edsl/inference_services/registry.py +41 -41
  88. edsl/inference_services/write_available.py +10 -10
  89. edsl/jobs/AnswerQuestionFunctionConstructor.py +223 -0
  90. edsl/jobs/Answers.py +43 -56
  91. edsl/jobs/FetchInvigilator.py +47 -0
  92. edsl/jobs/InterviewTaskManager.py +98 -0
  93. edsl/jobs/InterviewsConstructor.py +50 -0
  94. edsl/jobs/Jobs.py +823 -898
  95. edsl/jobs/JobsChecks.py +172 -147
  96. edsl/jobs/JobsComponentConstructor.py +189 -0
  97. edsl/jobs/JobsPrompts.py +270 -268
  98. edsl/jobs/JobsRemoteInferenceHandler.py +311 -239
  99. edsl/jobs/JobsRemoteInferenceLogger.py +239 -0
  100. edsl/jobs/RequestTokenEstimator.py +30 -0
  101. edsl/jobs/__init__.py +1 -1
  102. edsl/jobs/async_interview_runner.py +138 -0
  103. edsl/jobs/buckets/BucketCollection.py +104 -63
  104. edsl/jobs/buckets/ModelBuckets.py +65 -65
  105. edsl/jobs/buckets/TokenBucket.py +283 -251
  106. edsl/jobs/buckets/TokenBucketAPI.py +211 -0
  107. edsl/jobs/buckets/TokenBucketClient.py +191 -0
  108. edsl/jobs/check_survey_scenario_compatibility.py +85 -0
  109. edsl/jobs/data_structures.py +120 -0
  110. edsl/jobs/decorators.py +35 -0
  111. edsl/jobs/interviews/Interview.py +396 -661
  112. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  113. edsl/jobs/interviews/InterviewExceptionEntry.py +186 -186
  114. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  115. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  116. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  117. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  118. edsl/jobs/interviews/ReportErrors.py +66 -66
  119. edsl/jobs/interviews/interview_status_enum.py +9 -9
  120. edsl/jobs/jobs_status_enums.py +9 -0
  121. edsl/jobs/loggers/HTMLTableJobLogger.py +304 -0
  122. edsl/jobs/results_exceptions_handler.py +98 -0
  123. edsl/jobs/runners/JobsRunnerAsyncio.py +151 -466
  124. edsl/jobs/runners/JobsRunnerStatus.py +297 -330
  125. edsl/jobs/tasks/QuestionTaskCreator.py +244 -242
  126. edsl/jobs/tasks/TaskCreators.py +64 -64
  127. edsl/jobs/tasks/TaskHistory.py +470 -450
  128. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  129. edsl/jobs/tasks/task_status_enum.py +161 -163
  130. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  131. edsl/jobs/tokens/TokenUsage.py +34 -34
  132. edsl/language_models/ComputeCost.py +63 -0
  133. edsl/language_models/LanguageModel.py +626 -668
  134. edsl/language_models/ModelList.py +164 -155
  135. edsl/language_models/PriceManager.py +127 -0
  136. edsl/language_models/RawResponseHandler.py +106 -0
  137. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  138. edsl/language_models/ServiceDataSources.py +0 -0
  139. edsl/language_models/__init__.py +2 -3
  140. edsl/language_models/fake_openai_call.py +15 -15
  141. edsl/language_models/fake_openai_service.py +61 -61
  142. edsl/language_models/key_management/KeyLookup.py +63 -0
  143. edsl/language_models/key_management/KeyLookupBuilder.py +273 -0
  144. edsl/language_models/key_management/KeyLookupCollection.py +38 -0
  145. edsl/language_models/key_management/__init__.py +0 -0
  146. edsl/language_models/key_management/models.py +131 -0
  147. edsl/language_models/model.py +256 -0
  148. edsl/language_models/repair.py +156 -156
  149. edsl/language_models/utilities.py +65 -64
  150. edsl/notebooks/Notebook.py +263 -258
  151. edsl/notebooks/NotebookToLaTeX.py +142 -0
  152. edsl/notebooks/__init__.py +1 -1
  153. edsl/prompts/Prompt.py +352 -362
  154. edsl/prompts/__init__.py +2 -2
  155. edsl/questions/ExceptionExplainer.py +77 -0
  156. edsl/questions/HTMLQuestion.py +103 -0
  157. edsl/questions/QuestionBase.py +518 -664
  158. edsl/questions/QuestionBasePromptsMixin.py +221 -217
  159. edsl/questions/QuestionBudget.py +227 -227
  160. edsl/questions/QuestionCheckBox.py +359 -359
  161. edsl/questions/QuestionExtract.py +180 -182
  162. edsl/questions/QuestionFreeText.py +113 -114
  163. edsl/questions/QuestionFunctional.py +166 -166
  164. edsl/questions/QuestionList.py +223 -231
  165. edsl/questions/QuestionMatrix.py +265 -0
  166. edsl/questions/QuestionMultipleChoice.py +330 -286
  167. edsl/questions/QuestionNumerical.py +151 -153
  168. edsl/questions/QuestionRank.py +314 -324
  169. edsl/questions/Quick.py +41 -41
  170. edsl/questions/SimpleAskMixin.py +74 -73
  171. edsl/questions/__init__.py +27 -26
  172. edsl/questions/{AnswerValidatorMixin.py → answer_validator_mixin.py} +334 -289
  173. edsl/questions/compose_questions.py +98 -98
  174. edsl/questions/data_structures.py +20 -0
  175. edsl/questions/decorators.py +21 -21
  176. edsl/questions/derived/QuestionLikertFive.py +76 -76
  177. edsl/questions/derived/QuestionLinearScale.py +90 -87
  178. edsl/questions/derived/QuestionTopK.py +93 -93
  179. edsl/questions/derived/QuestionYesNo.py +82 -82
  180. edsl/questions/descriptors.py +427 -413
  181. edsl/questions/loop_processor.py +149 -0
  182. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  183. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  184. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  185. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  186. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  187. edsl/questions/prompt_templates/question_list.jinja +17 -17
  188. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  189. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  190. edsl/questions/{QuestionBaseGenMixin.py → question_base_gen_mixin.py} +168 -161
  191. edsl/questions/question_registry.py +177 -177
  192. edsl/questions/{RegisterQuestionsMeta.py → register_questions_meta.py} +71 -71
  193. edsl/questions/{ResponseValidatorABC.py → response_validator_abc.py} +188 -174
  194. edsl/questions/response_validator_factory.py +34 -0
  195. edsl/questions/settings.py +12 -12
  196. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  197. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  198. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  199. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  200. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  201. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  202. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  203. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  204. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  205. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  206. edsl/questions/templates/list/question_presentation.jinja +5 -5
  207. edsl/questions/templates/matrix/__init__.py +1 -0
  208. edsl/questions/templates/matrix/answering_instructions.jinja +5 -0
  209. edsl/questions/templates/matrix/question_presentation.jinja +20 -0
  210. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  211. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  212. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  213. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  214. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  215. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  216. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  217. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  218. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  219. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  220. edsl/results/CSSParameterizer.py +108 -108
  221. edsl/results/Dataset.py +587 -424
  222. edsl/results/DatasetExportMixin.py +594 -731
  223. edsl/results/DatasetTree.py +295 -275
  224. edsl/results/MarkdownToDocx.py +122 -0
  225. edsl/results/MarkdownToPDF.py +111 -0
  226. edsl/results/Result.py +557 -465
  227. edsl/results/Results.py +1183 -1165
  228. edsl/results/ResultsExportMixin.py +45 -43
  229. edsl/results/ResultsGGMixin.py +121 -121
  230. edsl/results/TableDisplay.py +125 -198
  231. edsl/results/TextEditor.py +50 -0
  232. edsl/results/__init__.py +2 -2
  233. edsl/results/file_exports.py +252 -0
  234. edsl/results/{ResultsFetchMixin.py → results_fetch_mixin.py} +33 -33
  235. edsl/results/{Selector.py → results_selector.py} +145 -135
  236. edsl/results/{ResultsToolsMixin.py → results_tools_mixin.py} +98 -98
  237. edsl/results/smart_objects.py +96 -0
  238. edsl/results/table_data_class.py +12 -0
  239. edsl/results/table_display.css +77 -77
  240. edsl/results/table_renderers.py +118 -0
  241. edsl/results/tree_explore.py +115 -115
  242. edsl/scenarios/ConstructDownloadLink.py +109 -0
  243. edsl/scenarios/DocumentChunker.py +102 -0
  244. edsl/scenarios/DocxScenario.py +16 -0
  245. edsl/scenarios/FileStore.py +511 -632
  246. edsl/scenarios/PdfExtractor.py +40 -0
  247. edsl/scenarios/Scenario.py +498 -601
  248. edsl/scenarios/ScenarioHtmlMixin.py +65 -64
  249. edsl/scenarios/ScenarioList.py +1458 -1287
  250. edsl/scenarios/ScenarioListExportMixin.py +45 -52
  251. edsl/scenarios/ScenarioListPdfMixin.py +239 -261
  252. edsl/scenarios/__init__.py +3 -4
  253. edsl/scenarios/directory_scanner.py +96 -0
  254. edsl/scenarios/file_methods.py +85 -0
  255. edsl/scenarios/handlers/__init__.py +13 -0
  256. edsl/scenarios/handlers/csv.py +38 -0
  257. edsl/scenarios/handlers/docx.py +76 -0
  258. edsl/scenarios/handlers/html.py +37 -0
  259. edsl/scenarios/handlers/json.py +111 -0
  260. edsl/scenarios/handlers/latex.py +5 -0
  261. edsl/scenarios/handlers/md.py +51 -0
  262. edsl/scenarios/handlers/pdf.py +68 -0
  263. edsl/scenarios/handlers/png.py +39 -0
  264. edsl/scenarios/handlers/pptx.py +105 -0
  265. edsl/scenarios/handlers/py.py +294 -0
  266. edsl/scenarios/handlers/sql.py +313 -0
  267. edsl/scenarios/handlers/sqlite.py +149 -0
  268. edsl/scenarios/handlers/txt.py +33 -0
  269. edsl/scenarios/{ScenarioJoin.py → scenario_join.py} +131 -127
  270. edsl/scenarios/scenario_selector.py +156 -0
  271. edsl/shared.py +1 -1
  272. edsl/study/ObjectEntry.py +173 -173
  273. edsl/study/ProofOfWork.py +113 -113
  274. edsl/study/SnapShot.py +80 -80
  275. edsl/study/Study.py +521 -528
  276. edsl/study/__init__.py +4 -4
  277. edsl/surveys/ConstructDAG.py +92 -0
  278. edsl/surveys/DAG.py +148 -148
  279. edsl/surveys/EditSurvey.py +221 -0
  280. edsl/surveys/InstructionHandler.py +100 -0
  281. edsl/surveys/Memory.py +31 -31
  282. edsl/surveys/MemoryManagement.py +72 -0
  283. edsl/surveys/MemoryPlan.py +244 -244
  284. edsl/surveys/Rule.py +327 -326
  285. edsl/surveys/RuleCollection.py +385 -387
  286. edsl/surveys/RuleManager.py +172 -0
  287. edsl/surveys/Simulator.py +75 -0
  288. edsl/surveys/Survey.py +1280 -1801
  289. edsl/surveys/SurveyCSS.py +273 -261
  290. edsl/surveys/SurveyExportMixin.py +259 -259
  291. edsl/surveys/{SurveyFlowVisualizationMixin.py → SurveyFlowVisualization.py} +181 -179
  292. edsl/surveys/SurveyQualtricsImport.py +284 -284
  293. edsl/surveys/SurveyToApp.py +141 -0
  294. edsl/surveys/__init__.py +5 -3
  295. edsl/surveys/base.py +53 -53
  296. edsl/surveys/descriptors.py +60 -56
  297. edsl/surveys/instructions/ChangeInstruction.py +48 -49
  298. edsl/surveys/instructions/Instruction.py +56 -65
  299. edsl/surveys/instructions/InstructionCollection.py +82 -77
  300. edsl/templates/error_reporting/base.html +23 -23
  301. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  302. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  303. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  304. edsl/templates/error_reporting/interview_details.html +115 -115
  305. edsl/templates/error_reporting/interviews.html +19 -19
  306. edsl/templates/error_reporting/overview.html +4 -4
  307. edsl/templates/error_reporting/performance_plot.html +1 -1
  308. edsl/templates/error_reporting/report.css +73 -73
  309. edsl/templates/error_reporting/report.html +117 -117
  310. edsl/templates/error_reporting/report.js +25 -25
  311. edsl/test_h +1 -0
  312. edsl/tools/__init__.py +1 -1
  313. edsl/tools/clusters.py +192 -192
  314. edsl/tools/embeddings.py +27 -27
  315. edsl/tools/embeddings_plotting.py +118 -118
  316. edsl/tools/plotting.py +112 -112
  317. edsl/tools/summarize.py +18 -18
  318. edsl/utilities/PrettyList.py +56 -0
  319. edsl/utilities/SystemInfo.py +28 -28
  320. edsl/utilities/__init__.py +22 -22
  321. edsl/utilities/ast_utilities.py +25 -25
  322. edsl/utilities/data/Registry.py +6 -6
  323. edsl/utilities/data/__init__.py +1 -1
  324. edsl/utilities/data/scooter_results.json +1 -1
  325. edsl/utilities/decorators.py +77 -77
  326. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  327. edsl/utilities/gcp_bucket/example.py +50 -0
  328. edsl/utilities/interface.py +627 -627
  329. edsl/utilities/is_notebook.py +18 -0
  330. edsl/utilities/is_valid_variable_name.py +11 -0
  331. edsl/utilities/naming_utilities.py +263 -263
  332. edsl/utilities/remove_edsl_version.py +24 -0
  333. edsl/utilities/repair_functions.py +28 -28
  334. edsl/utilities/restricted_python.py +70 -70
  335. edsl/utilities/utilities.py +436 -424
  336. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev4.dist-info}/LICENSE +21 -21
  337. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev4.dist-info}/METADATA +13 -11
  338. edsl-0.1.39.dev4.dist-info/RECORD +361 -0
  339. edsl/language_models/KeyLookup.py +0 -30
  340. edsl/language_models/registry.py +0 -190
  341. edsl/language_models/unused/ReplicateBase.py +0 -83
  342. edsl/results/ResultsDBMixin.py +0 -238
  343. edsl-0.1.39.dev3.dist-info/RECORD +0 -277
  344. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev4.dist-info}/WHEEL +0 -0
@@ -1,387 +1,385 @@
1
- """A collection of rules for a survey."""
2
-
3
- from typing import List, Union, Any, Optional
4
- from collections import defaultdict, UserList
5
-
6
- from edsl.exceptions import (
7
- SurveyRuleCannotEvaluateError,
8
- SurveyRuleCollectionHasNoRulesAtNodeError,
9
- )
10
- from edsl.utilities.interface import print_table_with_rich
11
- from edsl.surveys.Rule import Rule
12
- from edsl.surveys.base import EndOfSurvey
13
- from edsl.surveys.DAG import DAG
14
-
15
- # from graphlib import TopologicalSorter
16
-
17
- from collections import namedtuple
18
-
19
- NextQuestion = namedtuple(
20
- "NextQuestion", "next_q, num_rules_found, expressions_evaluating_to_true, priority"
21
- )
22
-
23
-
24
- class RuleCollection(UserList):
25
- """A collection of rules for a particular survey."""
26
-
27
- def __init__(self, num_questions: Optional[int] = None, rules: List[Rule] = None):
28
- """Initialize the RuleCollection object.
29
-
30
- :param num_questions: The number of questions in the survey.
31
- :param rules: A list of Rule objects.
32
- """
33
- super().__init__(rules or [])
34
- self.num_questions = num_questions
35
-
36
- def __repr__(self):
37
- """Return a string representation of the RuleCollection object.
38
-
39
- Example usage:
40
-
41
- .. code-block:: python
42
-
43
- rule_collection = RuleCollection.example()
44
- _ = eval(repr(rule_collection))
45
-
46
- """
47
- return f"RuleCollection(rules={self.data}, num_questions={self.num_questions})"
48
-
49
- def to_dict(self, add_edsl_version=True):
50
- """Create a dictionary representation of the RuleCollection object."""
51
- return {
52
- "rules": [rule.to_dict() for rule in self],
53
- "num_questions": self.num_questions,
54
- }
55
-
56
- @classmethod
57
- def from_dict(cls, rule_collection_dict):
58
- """Create a RuleCollection object from a dictionary.
59
-
60
- >>> rule_collection = RuleCollection.example()
61
- >>> rule_collection_dict = rule_collection.to_dict()
62
- >>> new_rule_collection = RuleCollection.from_dict(rule_collection_dict)
63
- >>> repr(new_rule_collection) == repr(rule_collection)
64
- True
65
- """
66
- rules = [
67
- Rule.from_dict(rule_dict) for rule_dict in rule_collection_dict["rules"]
68
- ]
69
- num_questions = rule_collection_dict["num_questions"]
70
- new_rc = cls(rules=rules)
71
- new_rc.num_questions = num_questions
72
- return new_rc
73
-
74
- def add_rule(self, rule: Rule) -> None:
75
- """Add a rule to a survey.
76
-
77
- >>> rule_collection = RuleCollection()
78
- >>> rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}))
79
- >>> len(rule_collection)
80
- 1
81
-
82
- >>> rule_collection = RuleCollection()
83
- >>> r = Rule(current_q=1, expression="True", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule = True)
84
- >>> rule_collection.add_rule(r)
85
- >>> rule_collection[0] == r
86
- True
87
- >>> len(rule_collection.applicable_rules(1, before_rule=True))
88
- 1
89
- >>> len(rule_collection.applicable_rules(1, before_rule=False))
90
- 0
91
- """
92
- self.append(rule)
93
-
94
- def show_rules(self) -> None:
95
- """Print the rules in a table.
96
-
97
-
98
- .. code-block:: python
99
-
100
- rule_collection = RuleCollection.example()
101
- rule_collection.show_rules()
102
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
103
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
104
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
105
- │ 1 │ q1 == 'yes' │ 3 │ 1 │ False │
106
- │ 1 │ q1 == 'no' │ 2 │ 1 │ False │
107
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
108
- """
109
- keys = ["current_q", "expression", "next_q", "priority", "before_rule"]
110
- rule_list = []
111
- for rule in sorted(self, key=lambda r: r.current_q):
112
- rule_list.append({k: getattr(rule, k) for k in keys})
113
-
114
- print_table_with_rich(rule_list)
115
-
116
- def skip_question_before_running(self, q_now: int, answers: dict[str, Any]) -> bool:
117
- """Determine if a question should be skipped before running the question.
118
-
119
- :param q_now: The current question index.
120
- :param answers: The answers to the survey questions.
121
-
122
- >>> rule_collection = RuleCollection()
123
- >>> r = Rule(current_q=1, expression="True", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
124
- >>> rule_collection.add_rule(r)
125
- >>> rule_collection.skip_question_before_running(1, {})
126
- True
127
-
128
- >>> rule_collection = RuleCollection()
129
- >>> r = Rule(current_q=1, expression="False", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
130
- >>> rule_collection.add_rule(r)
131
- >>> rule_collection.skip_question_before_running(1, {})
132
- False
133
-
134
- """
135
- for rule in self.applicable_rules(q_now, before_rule=True):
136
- if rule.evaluate(answers):
137
- return True
138
- return False
139
-
140
- def applicable_rules(self, q_now: int, before_rule: bool = False) -> list:
141
- """Show the rules that apply at the current node.
142
-
143
- :param q_now: The current question index.
144
- :param before_rule: If True, return rules that are of the type that apply before the question is asked.
145
-
146
- Example usage:
147
-
148
- >>> rule_collection = RuleCollection.example()
149
- >>> rule_collection.applicable_rules(1)
150
- [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False)]
151
-
152
- The default is that the rule is applied after the question is asked.
153
- If we want to see the rules that apply before the question is asked, we can set before_rule=True.
154
-
155
- .. code-block:: python
156
-
157
- rule_collection = RuleCollection.example()
158
- rule_collection.applicable_rules(1)
159
- [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4})]
160
-
161
- More than one rule can apply. For example, suppose we are at node 1.
162
- We could have three rules:
163
- 1. "q1 == 'a' ==> 3
164
- 2. "q1 == 'b' ==> 4
165
- 3. "q1 == 'c' ==> 5
166
- """
167
- return [
168
- rule
169
- for rule in self
170
- if rule.current_q == q_now and rule.before_rule == before_rule
171
- ]
172
-
173
- def next_question(self, q_now: int, answers: dict[str, Any]) -> NextQuestion:
174
- """Find the next question by index, given the rule collection.
175
-
176
- This rule is applied after the question is answered.
177
-
178
- :param q_now: The current question index.
179
- :param answers: The answers to the survey questions so far, including the current question.
180
-
181
- >>> rule_collection = RuleCollection.example()
182
- >>> rule_collection.next_question(1, {'q1': 'yes'})
183
- NextQuestion(next_q=3, num_rules_found=2, expressions_evaluating_to_true=1, priority=1)
184
-
185
- """
186
- # # is this the first question? If it is, we need to check if it should be skipped.
187
- # if q_now == 0:
188
- # if self.skip_question_before_running(q_now, answers):
189
- # return NextQuestion(
190
- # next_q=q_now + 1,
191
- # num_rules_found=0,
192
- # expressions_evaluating_to_true=0,
193
- # priority=-1,
194
- # )
195
-
196
- # breakpoint()
197
- expressions_evaluating_to_true = 0
198
- next_q = None
199
- highest_priority = -2 # start with -2 to 'pick up' the default rule added
200
- num_rules_found = 0
201
-
202
- for rule in self.applicable_rules(q_now, before_rule=False):
203
- num_rules_found += 1
204
- try:
205
- if rule.evaluate(answers): # evaluates to True
206
- expressions_evaluating_to_true += 1
207
- if rule.priority > highest_priority: # higher priority
208
- # we have a new champ!
209
- next_q, highest_priority = rule.next_q, rule.priority
210
- except SurveyRuleCannotEvaluateError:
211
- raise
212
-
213
- if num_rules_found == 0:
214
- raise SurveyRuleCollectionHasNoRulesAtNodeError(
215
- f"No rules found for question {q_now}"
216
- )
217
-
218
- # breakpoint()
219
- ## Now we need to check if the *next question* has any 'before; rules that we should follow
220
- for rule in self.applicable_rules(next_q, before_rule=True):
221
- if rule.evaluate(answers): # rule evaluates to True
222
- return self.next_question(next_q, answers)
223
-
224
- return NextQuestion(
225
- next_q, num_rules_found, expressions_evaluating_to_true, highest_priority
226
- )
227
-
228
- @property
229
- def non_default_rules(self) -> List[Rule]:
230
- """Return all rules that are not the default rule.
231
-
232
- >>> rule_collection = RuleCollection.example()
233
- >>> len(rule_collection.non_default_rules)
234
- 2
235
-
236
- Example usage:
237
-
238
- .. code-block:: python
239
-
240
- rule_collection = RuleCollection.example()
241
- len(rule_collection.non_default_rules)
242
- 2
243
-
244
- """
245
- return [rule for rule in self if rule.priority > -1]
246
-
247
- def keys_between(self, start_q, end_q, right_inclusive=True):
248
- """Return a list of all question indices between start_q and end_q.
249
-
250
- Example usage:
251
-
252
- .. code-block:: python
253
-
254
- rule_collection = RuleCollection(num_questions=5)
255
- rule_collection.keys_between(1, 3)
256
- [2, 3]
257
- rule_collection.keys_between(1, 4)
258
- [2, 3, 4]
259
- rule_collection.keys_between(1, EndOfSurvey, right_inclusive=False)
260
- [2, 3]
261
-
262
- """
263
- # If it's the end of the survey, all questions between the start_q and the end of the survey
264
- # now depend on the start_q
265
- if end_q == EndOfSurvey:
266
- if self.num_questions is None:
267
- raise ValueError(
268
- "Cannot determine DAG when EndOfSurvey and when num_questions is not known."
269
- )
270
- end_q = self.num_questions - 1
271
-
272
- question_range = list(range(start_q + 1, end_q + int(right_inclusive)))
273
-
274
- return question_range
275
-
276
- @property
277
- def dag(self) -> dict:
278
- """
279
- Find the DAG of the survey, based on the skip logic.
280
-
281
- Keys are children questions; the list of values are nodes that must be answered first
282
-
283
- Rules are designated at the current question and then direct where
284
- control goes next. As such, the destination nodes are the keys
285
- and the current nodes are the values. Furthermore, all questions between
286
- the current and destination nodes are also included as keys, as they will depend
287
- on the answer to the focal node as well.
288
-
289
- For exmaple, if we have a rule that says "if q1 == 'yes', go to q3", then q3 depends on q1, but so does q2.
290
- So the DAG would be {3: [1], 2: [1]}.
291
-
292
- Example usage:
293
-
294
- .. code-block:: python
295
-
296
- rule_collection = RuleCollection(num_questions=5)
297
- qn2i = {'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}
298
- rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index = qn2i))
299
- rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index = qn2i))
300
- rule_collection.dag
301
- {2: {1}, 3: {1}}
302
-
303
- """
304
- children_to_parents = defaultdict(set)
305
- # We are only interested in non-default rules. Default rules are those
306
- # that just go to the next question, so they don't add any dependencies
307
-
308
- ## I think for a skip-question, the potenially-skippable question
309
- ## depends on all the other questions bein answered first.
310
- for rule in self.non_default_rules:
311
- if not rule.before_rule:
312
- # for a regular rule, the next question depends on the current question answer
313
- current_q, next_q = rule.current_q, rule.next_q
314
- for q in self.keys_between(current_q, next_q):
315
- children_to_parents[q].add(current_q)
316
- else:
317
- # for the 'before rule' skipping depends on all previous answers.
318
- focal_q = rule.current_q
319
- for q in range(0, focal_q):
320
- children_to_parents[focal_q].add(q)
321
-
322
- return DAG(dict(sorted(children_to_parents.items())))
323
-
324
- def detect_cycles(self):
325
- """
326
- Detect cycles in the survey rules using depth-first search.
327
-
328
- :return: A list of cycles if any are found, otherwise an empty list.
329
- """
330
- dag = self.dag
331
- visited = set()
332
- path = []
333
- cycles = []
334
-
335
- def dfs(node):
336
- if node in path:
337
- cycle = path[path.index(node) :]
338
- cycles.append(cycle + [node])
339
- return
340
-
341
- if node in visited:
342
- return
343
-
344
- visited.add(node)
345
- path.append(node)
346
-
347
- for child in dag.get(node, []):
348
- dfs(child)
349
-
350
- path.pop()
351
-
352
- for node in dag:
353
- if node not in visited:
354
- dfs(node)
355
-
356
- return cycles
357
-
358
- @classmethod
359
- def example(cls):
360
- """Create an example RuleCollection object."""
361
- qn2i = {"q1": 1, "q2": 2, "q3": 3, "q4": 4}
362
- return cls(
363
- num_questions=5,
364
- rules=[
365
- Rule(
366
- current_q=1,
367
- expression="q1 == 'yes'",
368
- next_q=3,
369
- priority=1,
370
- question_name_to_index=qn2i,
371
- ),
372
- Rule(
373
- current_q=1,
374
- expression="q1 == 'no'",
375
- next_q=2,
376
- priority=1,
377
- question_name_to_index=qn2i,
378
- ),
379
- ],
380
- )
381
-
382
-
383
- if __name__ == "__main__":
384
- # pass
385
- import doctest
386
-
387
- doctest.testmod(optionflags=doctest.ELLIPSIS)
1
+ """A collection of rules for a survey."""
2
+
3
+ from typing import List, Union, Any, Optional
4
+ from collections import defaultdict, UserList, namedtuple
5
+
6
+ from edsl.exceptions.surveys import (
7
+ SurveyRuleCannotEvaluateError,
8
+ SurveyRuleCollectionHasNoRulesAtNodeError,
9
+ )
10
+
11
+ from edsl.surveys.Rule import Rule
12
+ from edsl.surveys.base import EndOfSurvey
13
+ from edsl.surveys.DAG import DAG
14
+
15
+
16
+ NextQuestion = namedtuple(
17
+ "NextQuestion", "next_q, num_rules_found, expressions_evaluating_to_true, priority"
18
+ )
19
+
20
+
21
+ class RuleCollection(UserList):
22
+ """A collection of rules for a particular survey."""
23
+
24
+ def __init__(self, num_questions: Optional[int] = None, rules: List[Rule] = None):
25
+ """Initialize the RuleCollection object.
26
+
27
+ :param num_questions: The number of questions in the survey.
28
+ :param rules: A list of Rule objects.
29
+ """
30
+ super().__init__(rules or [])
31
+ self.num_questions = num_questions
32
+
33
+ def __repr__(self):
34
+ """Return a string representation of the RuleCollection object.
35
+
36
+ Example usage:
37
+
38
+ .. code-block:: python
39
+
40
+ rule_collection = RuleCollection.example()
41
+ _ = eval(repr(rule_collection))
42
+
43
+ """
44
+ return f"RuleCollection(rules={self.data}, num_questions={self.num_questions})"
45
+
46
+ def to_dataset(self):
47
+ """Return a Dataset object representation of the RuleCollection object."""
48
+ from edsl.results.Dataset import Dataset
49
+
50
+ keys = ["current_q", "expression", "next_q", "priority", "before_rule"]
51
+ rule_list = {}
52
+ for rule in sorted(self, key=lambda r: r.current_q):
53
+ for k in keys:
54
+ rule_list.setdefault(k, []).append(getattr(rule, k))
55
+
56
+ return Dataset([{k: v} for k, v in rule_list.items()])
57
+
58
+ def _repr_html_(self):
59
+ """Return an HTML representation of the RuleCollection object."""
60
+ from edsl.results.Dataset import Dataset
61
+
62
+ return self.to_dataset()._repr_html_()
63
+
64
+ def to_dict(self, add_edsl_version=True):
65
+ """Create a dictionary representation of the RuleCollection object."""
66
+ return {
67
+ "rules": [rule.to_dict() for rule in self],
68
+ "num_questions": self.num_questions,
69
+ }
70
+
71
+ @classmethod
72
+ def from_dict(cls, rule_collection_dict):
73
+ """Create a RuleCollection object from a dictionary.
74
+
75
+ >>> rule_collection = RuleCollection.example()
76
+ >>> rule_collection_dict = rule_collection.to_dict()
77
+ >>> new_rule_collection = RuleCollection.from_dict(rule_collection_dict)
78
+ >>> repr(new_rule_collection) == repr(rule_collection)
79
+ True
80
+ """
81
+ rules = [
82
+ Rule.from_dict(rule_dict) for rule_dict in rule_collection_dict["rules"]
83
+ ]
84
+ num_questions = rule_collection_dict["num_questions"]
85
+ new_rc = cls(rules=rules)
86
+ new_rc.num_questions = num_questions
87
+ return new_rc
88
+
89
+ def add_rule(self, rule: Rule) -> None:
90
+ """Add a rule to a survey.
91
+
92
+ >>> rule_collection = RuleCollection()
93
+ >>> rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}))
94
+ >>> len(rule_collection)
95
+ 1
96
+
97
+ >>> rule_collection = RuleCollection()
98
+ >>> r = Rule(current_q=1, expression="True", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule = True)
99
+ >>> rule_collection.add_rule(r)
100
+ >>> rule_collection[0] == r
101
+ True
102
+ >>> len(rule_collection.applicable_rules(1, before_rule=True))
103
+ 1
104
+ >>> len(rule_collection.applicable_rules(1, before_rule=False))
105
+ 0
106
+ """
107
+ self.append(rule)
108
+
109
+ def show_rules(self) -> None:
110
+ """Print the rules in a table.
111
+
112
+
113
+ .. code-block:: python
114
+
115
+ rule_collection = RuleCollection.example()
116
+ rule_collection.show_rules()
117
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
118
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
119
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
120
+ 1 │ q1 == 'yes' 3 │ 1 │ False │
121
+ │ 1 │ q1 == 'no' │ 2 │ 1 │ False │
122
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
123
+ """
124
+ return self.to_dataset()
125
+
126
+ def skip_question_before_running(self, q_now: int, answers: dict[str, Any]) -> bool:
127
+ """Determine if a question should be skipped before running the question.
128
+
129
+ :param q_now: The current question index.
130
+ :param answers: The answers to the survey questions.
131
+
132
+ >>> rule_collection = RuleCollection()
133
+ >>> r = Rule(current_q=1, expression="True", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
134
+ >>> rule_collection.add_rule(r)
135
+ >>> rule_collection.skip_question_before_running(1, {})
136
+ True
137
+
138
+ >>> rule_collection = RuleCollection()
139
+ >>> r = Rule(current_q=1, expression="False", next_q=2, priority=1, question_name_to_index={}, before_rule = True)
140
+ >>> rule_collection.add_rule(r)
141
+ >>> rule_collection.skip_question_before_running(1, {})
142
+ False
143
+
144
+ """
145
+ for rule in self.applicable_rules(q_now, before_rule=True):
146
+ if rule.evaluate(answers):
147
+ return True
148
+ return False
149
+
150
+ def applicable_rules(self, q_now: int, before_rule: bool = False) -> list:
151
+ """Show the rules that apply at the current node.
152
+
153
+ :param q_now: The current question index.
154
+ :param before_rule: If True, return rules that are of the type that apply before the question is asked.
155
+
156
+ Example usage:
157
+
158
+ >>> rule_collection = RuleCollection.example()
159
+ >>> rule_collection.applicable_rules(1)
160
+ [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}, before_rule=False)]
161
+
162
+ The default is that the rule is applied after the question is asked.
163
+ If we want to see the rules that apply before the question is asked, we can set before_rule=True.
164
+
165
+ .. code-block:: python
166
+
167
+ rule_collection = RuleCollection.example()
168
+ rule_collection.applicable_rules(1)
169
+ [Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}), Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index={'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4})]
170
+
171
+ More than one rule can apply. For example, suppose we are at node 1.
172
+ We could have three rules:
173
+ 1. "q1 == 'a' ==> 3
174
+ 2. "q1 == 'b' ==> 4
175
+ 3. "q1 == 'c' ==> 5
176
+ """
177
+ return [
178
+ rule
179
+ for rule in self
180
+ if rule.current_q == q_now and rule.before_rule == before_rule
181
+ ]
182
+
183
+ def next_question(self, q_now: int, answers: dict[str, Any]) -> NextQuestion:
184
+ """Find the next question by index, given the rule collection.
185
+
186
+ This rule is applied after the question is answered.
187
+
188
+ :param q_now: The current question index.
189
+ :param answers: The answers to the survey questions so far, including the current question.
190
+
191
+ >>> rule_collection = RuleCollection.example()
192
+ >>> rule_collection.next_question(1, {'q1': 'yes'})
193
+ NextQuestion(next_q=3, num_rules_found=2, expressions_evaluating_to_true=1, priority=1)
194
+
195
+ """
196
+ expressions_evaluating_to_true = 0
197
+ next_q = None
198
+ highest_priority = -2 # start with -2 to 'pick up' the default rule added
199
+ num_rules_found = 0
200
+
201
+ for rule in self.applicable_rules(q_now, before_rule=False):
202
+ num_rules_found += 1
203
+ try:
204
+ if rule.evaluate(answers): # evaluates to True
205
+ expressions_evaluating_to_true += 1
206
+ if rule.priority > highest_priority: # higher priority
207
+ # we have a new champ!
208
+ next_q, highest_priority = rule.next_q, rule.priority
209
+ except SurveyRuleCannotEvaluateError:
210
+ raise
211
+
212
+ if num_rules_found == 0:
213
+ raise SurveyRuleCollectionHasNoRulesAtNodeError(
214
+ f"No rules found for question {q_now}"
215
+ )
216
+
217
+ ## Now we need to check if the *next question* has any 'before; rules that we should follow
218
+ for rule in self.applicable_rules(next_q, before_rule=True):
219
+ if rule.evaluate(answers): # rule evaluates to True
220
+ return self.next_question(next_q, answers)
221
+
222
+ return NextQuestion(
223
+ next_q, num_rules_found, expressions_evaluating_to_true, highest_priority
224
+ )
225
+
226
+ @property
227
+ def non_default_rules(self) -> List[Rule]:
228
+ """Return all rules that are not the default rule.
229
+
230
+ >>> rule_collection = RuleCollection.example()
231
+ >>> len(rule_collection.non_default_rules)
232
+ 2
233
+
234
+ Example usage:
235
+
236
+ .. code-block:: python
237
+
238
+ rule_collection = RuleCollection.example()
239
+ len(rule_collection.non_default_rules)
240
+ 2
241
+
242
+ """
243
+ return [rule for rule in self if rule.priority > -1]
244
+
245
+ def keys_between(self, start_q, end_q, right_inclusive=True):
246
+ """Return a list of all question indices between start_q and end_q.
247
+
248
+ Example usage:
249
+
250
+ .. code-block:: python
251
+
252
+ rule_collection = RuleCollection(num_questions=5)
253
+ rule_collection.keys_between(1, 3)
254
+ [2, 3]
255
+ rule_collection.keys_between(1, 4)
256
+ [2, 3, 4]
257
+ rule_collection.keys_between(1, EndOfSurvey, right_inclusive=False)
258
+ [2, 3]
259
+
260
+ """
261
+ # If it's the end of the survey, all questions between the start_q and the end of the survey now depend on the start_q
262
+ if end_q == EndOfSurvey:
263
+ if self.num_questions is None:
264
+ raise ValueError(
265
+ "Cannot determine DAG when EndOfSurvey and when num_questions is not known."
266
+ )
267
+ end_q = self.num_questions - 1
268
+
269
+ question_range = list(range(start_q + 1, end_q + int(right_inclusive)))
270
+
271
+ return question_range
272
+
273
+ @property
274
+ def dag(self) -> dict:
275
+ """
276
+ Find the DAG of the survey, based on the skip logic.
277
+
278
+ Keys are children questions; the list of values are nodes that must be answered first
279
+
280
+ Rules are designated at the current question and then direct where
281
+ control goes next. As such, the destination nodes are the keys
282
+ and the current nodes are the values. Furthermore, all questions between
283
+ the current and destination nodes are also included as keys, as they will depend
284
+ on the answer to the focal node as well.
285
+
286
+ For exmaple, if we have a rule that says "if q1 == 'yes', go to q3", then q3 depends on q1, but so does q2.
287
+ So the DAG would be {3: [1], 2: [1]}.
288
+
289
+ Example usage:
290
+
291
+ .. code-block:: python
292
+
293
+ rule_collection = RuleCollection(num_questions=5)
294
+ qn2i = {'q1': 1, 'q2': 2, 'q3': 3, 'q4': 4}
295
+ rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'yes'", next_q=3, priority=1, question_name_to_index = qn2i))
296
+ rule_collection.add_rule(Rule(current_q=1, expression="q1 == 'no'", next_q=2, priority=1, question_name_to_index = qn2i))
297
+ rule_collection.dag
298
+ {2: {1}, 3: {1}}
299
+
300
+ """
301
+ children_to_parents = defaultdict(set)
302
+ # We are only interested in non-default rules. Default rules are those
303
+ # that just go to the next question, so they don't add any dependencies
304
+
305
+ ## I think for a skip-question, the potenially-skippable question
306
+ ## depends on all the other questions bein answered first.
307
+ for rule in self.non_default_rules:
308
+ if not rule.before_rule:
309
+ # for a regular rule, the next question depends on the current question answer
310
+ current_q, next_q = rule.current_q, rule.next_q
311
+ for q in self.keys_between(current_q, next_q):
312
+ children_to_parents[q].add(current_q)
313
+ else:
314
+ # for the 'before rule' skipping depends on all previous answers.
315
+ focal_q = rule.current_q
316
+ for q in range(0, focal_q):
317
+ children_to_parents[focal_q].add(q)
318
+
319
+ return DAG(dict(sorted(children_to_parents.items())))
320
+
321
+ def detect_cycles(self):
322
+ """
323
+ Detect cycles in the survey rules using depth-first search.
324
+
325
+ :return: A list of cycles if any are found, otherwise an empty list.
326
+ """
327
+ dag = self.dag
328
+ visited = set()
329
+ path = []
330
+ cycles = []
331
+
332
+ def dfs(node):
333
+ if node in path:
334
+ cycle = path[path.index(node) :]
335
+ cycles.append(cycle + [node])
336
+ return
337
+
338
+ if node in visited:
339
+ return
340
+
341
+ visited.add(node)
342
+ path.append(node)
343
+
344
+ for child in dag.get(node, []):
345
+ dfs(child)
346
+
347
+ path.pop()
348
+
349
+ for node in dag:
350
+ if node not in visited:
351
+ dfs(node)
352
+
353
+ return cycles
354
+
355
+ @classmethod
356
+ def example(cls):
357
+ """Create an example RuleCollection object."""
358
+ qn2i = {"q1": 1, "q2": 2, "q3": 3, "q4": 4}
359
+ return cls(
360
+ num_questions=5,
361
+ rules=[
362
+ Rule(
363
+ current_q=1,
364
+ expression="q1 == 'yes'",
365
+ next_q=3,
366
+ priority=1,
367
+ question_name_to_index=qn2i,
368
+ ),
369
+ Rule(
370
+ current_q=1,
371
+ expression="q1 == 'no'",
372
+ next_q=2,
373
+ priority=1,
374
+ question_name_to_index=qn2i,
375
+ ),
376
+ ],
377
+ )
378
+
379
+
380
+ if __name__ == "__main__":
381
+ import doctest
382
+
383
+ doctest.testmod(optionflags=doctest.ELLIPSIS)
384
+
385
+ print(RuleCollection.example()._repr_html_())