Compare commits

...

7 Commits

Author SHA1 Message Date
94f6fd3ae9 Fix main lib location 2020-03-22 17:44:59 +03:00
49bb3da4bb Add history for predictor moves 2020-03-22 14:38:05 +03:00
9e06a1c630 Write more tests 2020-03-22 13:30:37 +03:00
eb66b6904d Restore tests 2020-03-22 12:40:58 +03:00
4a18dc5808 Fix spellcheck 2020-03-22 10:48:29 +03:00
54a5b0683d Add circle ci badge 2020-03-22 10:20:33 +03:00
e3ea017315 Add junit report 2020-03-22 10:12:05 +03:00
16 changed files with 358 additions and 229 deletions

View File

@ -8,7 +8,9 @@ jobs:
- checkout
- run: npm ci
- run: npm run format-check
- run: npm run test
- run: npm run test:junit-report
- store_test_results:
path: test-results
build_and_publish:
docker:

1
.gitignore vendored
View File

@ -3,5 +3,6 @@
built/
coverage/
node_modules/
test-results/
var/
.npmrc

View File

@ -1,16 +1,18 @@
# Электронная гадалка
[![CircleCI](https://circleci.com/gh/anwinged/predictor/tree/master.svg?style=svg)](https://circleci.com/gh/anwinged/predictor/tree/master)
[Демоверсия][demo]
Алгоритм, который противостоит человеку, и на основе ходов пытается предсказать
следующих ход человека.
Игрок загаывает один из двух вариантов, а робот пытается его угадать.
Игрок загадывает один из двух вариантов, а робот пытается его угадать.
Если программе удалось угадать, то игрок теряет очко.
Если же программа не смогла предсказать выбор человека, то игрок зарабатывает очко.
Если программа не смогла предсказать выбор человека, то игрок зарабатывает очко.
Алгоритм реализован на основе [описания][algorithm]. В процессе реализации алгоритм слегка изменился.
В отличие от описания, здесь можно допольнительно указать количество вариантов.
В отличие от описания, здесь можно дополнительно указать количество вариантов.
С двумя вариантами будет игра "Чет - нечет", а с тремя - "Камень, ножницы, бумага".
Интересно то, что программу сложно обыграть. Игрок пытается обставить робота, но все равно
@ -34,7 +36,7 @@ const prediction = predictor.pass(1);
// Получение текущего счета
const score = predictor.score;
// Получение количества сделаннх ходов
// Получение количества сделанных ходов
const sc = predictor.stepCount();
```
@ -53,7 +55,7 @@ const sc = predictor.stepCount();
tools/build-docker
tools/npm run build
## Тестиирование
## Тестирование
tools/npm run test

53
package-lock.json generated
View File

@ -1119,6 +1119,12 @@
"supports-color": "^5.3.0"
}
},
"charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=",
"dev": true
},
"check-error": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
@ -1953,6 +1959,12 @@
"which": "^1.2.9"
}
},
"crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
"dev": true
},
"crypto-browserify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
@ -3576,6 +3588,17 @@
"object-visit": "^1.0.0"
}
},
"md5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.2.1.tgz",
"integrity": "sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=",
"dev": true,
"requires": {
"charenc": "~0.0.1",
"crypt": "~0.0.1",
"is-buffer": "~1.1.1"
}
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -3917,6 +3940,30 @@
}
}
},
"mocha-junit-reporter": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/mocha-junit-reporter/-/mocha-junit-reporter-1.23.3.tgz",
"integrity": "sha512-ed8LqbRj1RxZfjt/oC9t12sfrWsjZ3gNnbhV1nuj9R/Jb5/P3Xb4duv2eCfCDMYH+fEu0mqca7m4wsiVjsxsvA==",
"dev": true,
"requires": {
"debug": "^2.2.0",
"md5": "^2.1.0",
"mkdirp": "~0.5.1",
"strip-ansi": "^4.0.0",
"xml": "^1.0.0"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dev": true,
"requires": {
"ms": "2.0.0"
}
}
}
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -6195,6 +6242,12 @@
"typedarray-to-buffer": "^3.1.5"
}
},
"xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU=",
"dev": true
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -4,13 +4,14 @@
"description": "",
"author": "Anton Vakhrushev",
"license": "MIT",
"main": "built/predictor.js",
"main": "dist/predictor.js",
"repository": {
"type": "git",
"url": "https://github.com/anwinged/predictor.git"
},
"scripts": {
"test": "mocha",
"test:junit-report": "mocha --reporter mocha-junit-reporter --reporter-options mochaFile=./test-results/results.xml",
"coverage": "nyc mocha",
"build:dev": "webpack",
"build": "webpack --env.production",
@ -25,6 +26,7 @@
"@types/node": "^13.9.2",
"chai": "^4.2.0",
"mocha": "^7.1.1",
"mocha-junit-reporter": "^1.23.3",
"nyc": "^15.0.0",
"prettier": "^1.19.1",
"ts-loader": "^6.2.1",

View File

@ -1,49 +1,42 @@
import Journal from './Journal';
const DEFAULT_EPSILON = 0.01;
function create_key(steps: number[]): string {
return steps.join(':');
}
class Daemon {
/**
* @type {Number}
*/
base;
static DEFAULT_EPSILON = 0.01;
/**
* @type {Number}
*/
humanCount;
private readonly thisId: string;
/**
* @type {Number}
*/
robotCount;
private readonly base: number;
/**
* @type {Number}
*/
epsilon;
private readonly humanCount: number;
/**
* @type {Object}
*/
weights = {};
private readonly robotCount: number;
private readonly epsilon: number;
private weights: { [key: string]: number } = {};
constructor(
id: string,
base: number,
humanCount: number,
robotCount: number,
epsilon: number = DEFAULT_EPSILON
epsilon: number = Daemon.DEFAULT_EPSILON
) {
this.thisId = id;
this.base = base;
this.humanCount = humanCount;
this.robotCount = robotCount;
this.epsilon = epsilon;
}
get id(): string {
return this.thisId;
}
get power(): number {
return this.humanCount + this.robotCount;
}
@ -62,69 +55,35 @@ class Daemon {
return proposals.indexOf(maxWeight);
}
/**
* @param {Journal} journal
* @param {Number} humanValue
*/
adjust(journal, humanValue) {
adjust(journal: Journal, humanValue: number): void {
const steps = this._getStepSlice(journal);
const adjustmentWeight = this._getAdjustmentWeight(journal.length);
this._adjustWeight([...steps, humanValue], adjustmentWeight);
}
/**
* @param {Journal} journal
*
* @returns {Number[]}
*/
private _getStepSlice(journal) {
getWeights() {
return { ...this.weights };
}
private _getStepSlice(journal: Journal): number[] {
return journal.getLastMovements(this.humanCount, this.robotCount);
}
/**
* @param {Number} stepNumber
*
* @returns {Number}
*
* @private
*/
private _getAdjustmentWeight(stepNumber) {
private _getAdjustmentWeight(stepNumber: number): number {
return Math.pow(1 + this.epsilon, stepNumber);
}
/**
* @param {Number[]} steps
*
* @returns {Number}
*
* @private
*/
private _getWeight(steps: number[]): number {
const key = create_key(steps);
const weight = this.weights[key];
return weight as number;
return key in this.weights ? this.weights[key] : 0;
}
/**
* @param {Number[]} steps
* @param {Number} value
*
* @returns {Number}
*
* @private
*/
private _setWeight(steps, value) {
private _setWeight(steps: number[], value: number): void {
const key = create_key(steps);
this.weights[key] = value;
}
/**
* @param {Number[]} steps
* @param {Number} weight
*
* @private
*/
private _adjustWeight(steps, weight) {
private _adjustWeight(steps: number[], weight: number): void {
const currentWeight = this._getWeight(steps);
const newWeight = currentWeight + weight;
this._setWeight(steps, newWeight);

View File

@ -2,13 +2,20 @@
* Represents one game move.
*/
class Move {
public human: number;
public robot: number;
private readonly itsHuman: number;
private readonly itsRobot: number;
constructor(human: number, robot: number) {
this.human = human;
this.robot = robot;
this.itsHuman = human;
this.itsRobot = robot;
}
get human() {
return this.itsHuman;
}
get robot() {
return this.itsRobot;
}
}

View File

@ -2,15 +2,35 @@ import Daemon from './Daemon';
import Journal from './Journal';
import Supervisor from './Supervisor';
const DEFAULT_CONFIG = {
interface DaemonConfig {
id?: string;
human: number;
robot: number;
epsilon?: number;
}
interface PredictorConfig {
base: number;
supervisor_epsilon: number;
daemons: DaemonConfig[];
}
interface HistoryRecord {
score: number;
move: [number, number];
rates: { [id: string]: number };
weights: { [id: string]: any };
}
const DEFAULT_CONFIG: PredictorConfig = {
base: 2,
supervisor_epsilon: 0.01,
daemons: [
{ human: 2, robot: 2, epsilon: 0.01 },
{ human: 3, robot: 3, epsilon: 0.01 },
{ human: 4, robot: 4, epsilon: 0.01 },
{ human: 5, robot: 5, epsilon: 0.01 },
{ human: 6, robot: 6, epsilon: 0.01 },
{ human: 2, robot: 2 },
{ human: 3, robot: 3 },
{ human: 4, robot: 4 },
{ human: 5, robot: 5 },
{ human: 6, robot: 6 },
],
};
@ -18,73 +38,70 @@ export default class Predictor {
/**
* @type {Number}
*/
base;
readonly base: number;
/**
* @type {Number}
*/
score;
score: number;
/**
* @type {Journal}
*/
journal;
journal: Journal;
/**
* @type {Supervisor}
*/
supervisor;
supervisor: Supervisor;
/**
* @param {Object} config
*/
constructor(config = DEFAULT_CONFIG) {
history: HistoryRecord[];
constructor(config: PredictorConfig = DEFAULT_CONFIG) {
this.base = config.base;
this.score = 0;
this.journal = new Journal();
const daemons = this._createDaemons(config.daemons);
this.supervisor = new Supervisor(daemons, config.supervisor_epsilon);
this.history = [];
}
/**
* @param {Number|String} humanValue
*
* @returns {Number}
*/
pass(humanValue) {
const value = parseInt(humanValue, 10);
if (value < 0 || value >= this.base) {
pass(humanValue: number): number {
if (humanValue < 0 || humanValue >= this.base) {
throw new Error(`Passed value must be in [0, ${this.base})`);
}
const prediction = this.supervisor.predict(this.journal);
this.score += prediction === value ? -1 : 1;
this.supervisor.adjust(this.journal, value);
this.journal.makeMove(value, prediction);
this.score += prediction === humanValue ? -1 : 1;
this.supervisor.adjust(this.journal, humanValue);
this.journal.makeMove(humanValue, prediction);
this.history.push({
score: this.score,
move: [humanValue, prediction],
rates: this.supervisor.rates(),
weights: this.supervisor.weights(),
});
return prediction;
}
/**
* @param {Object} daemonConfigs
*
* @returns {Daemon[]}
*
* @private
*/
_createDaemons(daemonConfigs) {
showHistory(deep: number): HistoryRecord[] {
return this.history.slice(-deep);
}
private _createDaemons(daemonConfigs: DaemonConfig[]): Daemon[] {
return daemonConfigs.map(config => {
const epsilon = config.epsilon || Daemon.DEFAULT_EPSILON;
return new Daemon(
config.id ||
`daemon-${config.human}-${config.robot}-${epsilon}`,
this.base,
config.human,
config.robot,
config.epsilon || 0.01
epsilon
);
});
}
/**
* @returns {Number}
*/
stepCount() {
stepCount(): number {
return this.journal.length;
}
}

View File

@ -1,19 +1,34 @@
import Journal from './Journal';
import Daemon from './Daemon';
const DEFAULT_EPSILON = 0.01;
interface DaemonRate {
daemon: Daemon;
rate: number;
}
interface Prediction {
daemonRate: DaemonRate;
rate: number;
power: number;
value: number;
}
class Supervisor {
daemons: { daemon: Daemon; rate: number }[] = [];
static DEFAULT_EPSILON = 0.01;
daemonRates: DaemonRate[] = [];
readonly epsilon: number;
constructor(daemons: Daemon[], epsilon: number = DEFAULT_EPSILON) {
constructor(
daemons: Daemon[],
epsilon: number = Supervisor.DEFAULT_EPSILON
) {
if (!daemons || daemons.length === 0) {
throw Error('Empty daemon list');
}
this.daemons = daemons.map(daemon => ({
this.daemonRates = daemons.map(daemon => ({
daemon: daemon,
rate: 0,
}));
@ -21,11 +36,6 @@ class Supervisor {
this.epsilon = epsilon;
}
/**
* @param {Journal} journal
*
* @returns {Number}
*/
predict(journal: Journal): number {
const predictions = this._createPredictions(journal);
const ordered = this._sortPredictions(predictions);
@ -33,47 +43,45 @@ class Supervisor {
return ordered[0].value;
}
/**
* @param {Journal} journal
* @param {Number} humanValue
*/
adjust(journal: Journal, humanValue) {
adjust(journal: Journal, humanValue: number) {
const predictions = this._createPredictions(journal);
for (const prediction of predictions) {
if (prediction.value === humanValue) {
prediction.daemon.rate += this._getAdjustmentWeight(
prediction.daemonRate.rate += this._getAdjustmentWeight(
journal.length
);
}
prediction.daemon.daemon.adjust(journal, humanValue);
prediction.daemonRate.daemon.adjust(journal, humanValue);
}
}
/**
* @param {Journal} journal
*
* @returns {Array}
*
* @private
*/
private _createPredictions(journal: Journal) {
return this.daemons.map(daemon => ({
daemon: daemon,
power: daemon.daemon.power,
rate: daemon.rate,
value: daemon.daemon.predict(journal),
rates() {
const result = {};
this.daemonRates.forEach(r => {
result[r.daemon.id] = r.rate;
});
return result;
}
weights() {
const result = {};
this.daemonRates.forEach(r => {
result[r.daemon.id] = r.daemon.getWeights();
});
return result;
}
private _createPredictions(journal: Journal): Prediction[] {
return this.daemonRates.map(daemonRate => ({
daemonRate: daemonRate,
power: daemonRate.daemon.power,
rate: daemonRate.rate,
value: daemonRate.daemon.predict(journal),
}));
}
/**
* @param {Array} predictions
*
* @returns {Array}
*
* @private
*/
private _sortPredictions(predictions) {
return predictions.sort((result1, result2) => {
private _sortPredictions(predictions: Prediction[]) {
return predictions.sort((result1: Prediction, result2: Prediction) => {
const rateDiff = result2.rate - result1.rate;
if (Math.abs(rateDiff) > 0.000001) {
return rateDiff;

View File

@ -3,50 +3,61 @@ import { expect } from 'chai';
import Daemon from '../src/Daemon';
import Journal from '../src/Journal';
import Move from '../src/Move';
// it('Get prediction for beginning', function() {
// const m = new Journal();
// const d = new Daemon(2, 1, 1);
// const predicted = d.predict(m);
// expect(predicted).to.equals(0);
// });
describe('Daemon', function() {
it('Get prediction for beginning', function() {
const daemon = new Daemon('d1', 2, 1, 1);
expect('d1').to.equals(daemon.id);
it('Can get power', function() {
const d = new Daemon(2, 5, 8);
expect(d.power).to.eqls(13);
const predicted = daemon.predict(new Journal());
expect(0).to.equals(predicted);
});
// it('Daemon 1-1', function() {
// const m = new Journal();
// const d = new Daemon(2, 1, 1);
//
// const steps = [
// {
// prediction: 0,
// human: 1,
// },
// {
// prediction: 0,
// human: 1,
// },
// {
// prediction: 1,
// human: 1,
// },
// {
// prediction: 0,
// human: 1,
// },
// {
// prediction: 1,
// human: 1,
// },
// ];
//
// steps.forEach(step => {
// const prediction = d.predict(m);
// expect(prediction).to.eqls(step.prediction);
// d.adjust(m, step.human);
// m.makeMove(step.human, step.prediction);
// });
// });
it('Can get power', function() {
const d = new Daemon('d1', 2, 5, 8);
expect(13).to.eqls(d.power);
});
it('Can adjust', function() {
const journal = new Journal([new Move(0, 0), new Move(0, 0)]);
const daemon = new Daemon('d1', 2, 1, 1, 0.1);
daemon.adjust(journal, 1);
expect({ '0:0:1': 1.1 ** 2 }).to.eqls(daemon.getWeights());
});
it('Daemon 1-1', function() {
const journal = new Journal();
const daemon = new Daemon('d1', 2, 1, 1, 0.1);
const steps = [
{
prediction: 0,
human: 1,
},
{
prediction: 0,
human: 1,
},
{
prediction: 1,
human: 1,
},
{
prediction: 0,
human: 1,
},
{
prediction: 1,
human: 1,
},
];
steps.forEach(step => {
const prediction = daemon.predict(journal);
expect(prediction).to.eqls(step.prediction);
daemon.adjust(journal, step.human);
journal.makeMove(step.human, step.prediction);
});
});
});

View File

@ -4,20 +4,25 @@ import { expect } from 'chai';
import Journal from '../src/Journal';
import Move from '../src/Move';
describe('Journal', function() {
it('Create with empty constructor', function() {
const m = new Journal();
expect(m.getLastMovements(5, 5)).to.eqls([]);
const journal = new Journal();
expect(journal.length).to.equals(0);
expect(journal.getLastMovements(5, 5)).to.eqls([]);
});
it('Constructor with human steps', function() {
const m = new Journal([new Move(1, 1)]);
expect(m.getLastMovements(5, 5)).to.eqls([1, 1]);
const journal = new Journal([new Move(1, 1)]);
expect(journal.length).equals(1);
expect(journal.getLastMovements(5, 5)).to.eqls([1, 1]);
});
it('Make steps', function() {
const m = new Journal();
m.makeMove(1, 0);
expect(m.getLastMovements(5, 5)).to.eqls([0, 1]);
const journal = new Journal();
journal.makeMove(1, 0);
journal.makeMove(1, 1);
expect(journal.length).to.equals(2);
expect(journal.getLastMovements(2, 2)).to.eqls([0, 1, 1, 1]);
});
it('Get slice', function() {
@ -27,6 +32,6 @@ it('Get slice', function() {
new Move(0, 1),
new Move(1, 0),
]);
expect(m.getLastMovements(2, 2)).to.eqls([1, 0, 0, 1]);
});
});

17
tests/PredictorTest.ts Normal file
View File

@ -0,0 +1,17 @@
import { it, describe } from 'mocha';
import { expect } from 'chai';
import Predictor from '../src/Predictor';
describe('Predictor', function() {
it('Get prediction for one daemon state', function() {
const predictor = new Predictor({
base: 2,
supervisor_epsilon: 0.01,
daemons: [{ robot: 1, human: 1, epsilon: 0.01 }],
});
const predicted = predictor.pass(1);
expect(predicted).to.equals(0);
expect(predictor.stepCount()).to.equals(1);
});
});

35
tests/SupervisorTest.ts Normal file
View File

@ -0,0 +1,35 @@
import { it, describe } from 'mocha';
import { expect } from 'chai';
import Supervisor from '../src/Supervisor';
import Daemon from '../src/Daemon';
import Journal from '../src/Journal';
describe('Supervisor', function() {
it('Get prediction for one daemon state', function() {
const supervisor = new Supervisor(
[new Daemon('d1', 2, 1, 1, 0.1)],
0.1
);
const journal = new Journal();
const human1 = 1;
const predicted1 = supervisor.predict(journal);
expect(0).to.equals(predicted1, 'First prediction for empty journal');
journal.makeMove(human1, predicted1);
supervisor.adjust(journal, human1);
const human2 = 1;
const predicted2 = supervisor.predict(journal);
expect(1).to.equals(
predicted2,
`Second prediction for (${human1}, ${predicted1})`
);
journal.makeMove(human2, predicted1);
supervisor.adjust(journal, human2);
expect({ d1: 1.1 ** 2 }).to.eqls(supervisor.rates());
});
});

View File

@ -4,13 +4,18 @@ set -eu
source .env
TTY=
if [ -t 1 ] ; then
TTY=--tty
fi
docker run \
--rm \
--interactive \
--tty \
${TTY} \
--init \
--user "$(id -u):$(id -g)" \
--volume "$PWD:/srv/app" \
--workdir /srv/app \
--volume "$PWD:/app" \
--workdir /app \
${NODE_IMAGE} \
node "$@"

View File

@ -9,14 +9,19 @@ CONTAINER_CACHE_DIR=/tmp/.npm
mkdir -p ${HOST_CACHE_DIR}
TTY=
if [ -t 1 ] ; then
TTY=--tty
fi
docker run \
--rm \
--interactive \
--tty \
${TTY} \
--init \
--user "$UID:$(id -g)" \
--volume "$PWD:/srv/app" \
--volume "$PWD:/app" \
--env npm_config_cache="${CONTAINER_CACHE_DIR}" \
--workdir /srv/app \
--workdir /app \
${NODE_IMAGE} \
npm "$@"

View File

@ -10,7 +10,7 @@ docker run \
--tty \
--init \
--user "$(id -u):$(id -g)" \
--volume "$PWD:/srv/app" \
--workdir /srv/app \
--volume "$PWD:/app" \
--workdir /app \
${NODE_IMAGE} \
./node_modules/.bin/tsc "$@"